Spaces:
Sleeping
Sleeping
Commit
·
345b8ff
1
Parent(s):
69aea0d
document deletion and improve tenant ID management
Browse files- backend/api/mcp_clients/rag_client.py +65 -1
- backend/api/routes/rag.py +50 -0
- backend/mcp_servers/database.py +59 -0
- backend/mcp_servers/main.py +44 -1
- frontend/app/admin-rules/page.tsx +226 -3
- frontend/app/analytics/page.tsx +7 -3
- frontend/app/chat/page.tsx +7 -3
- frontend/app/ingestion/page.tsx +7 -3
- frontend/app/knowledge-base/page.tsx +130 -16
- frontend/app/layout.tsx +4 -1
- frontend/app/page.tsx +27 -23
- frontend/components/analytics-panel.tsx +2 -11
- frontend/components/chat-panel.tsx +9 -20
- frontend/components/knowledge-base-panel.tsx +214 -12
- frontend/components/tenant-selector.tsx +20 -0
- frontend/contexts/TenantContext.tsx +49 -0
- frontend/lib/constants.ts +3 -0
backend/api/mcp_clients/rag_client.py
CHANGED
|
@@ -10,7 +10,9 @@ class RAGClient:
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
def __init__(self):
|
| 13 |
-
self.base_url = os.getenv("RAG_MCP_URL")
|
|
|
|
|
|
|
| 14 |
self.search_endpoint = f"{self.base_url}/search"
|
| 15 |
self.ingest_endpoint = f"{self.base_url}/ingest"
|
| 16 |
|
|
@@ -89,3 +91,65 @@ class RAGClient:
|
|
| 89 |
except Exception as e:
|
| 90 |
print("RAG List Error:", e)
|
| 91 |
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
def __init__(self):
|
| 13 |
+
self.base_url = os.getenv("RAG_MCP_URL", "http://localhost:8001")
|
| 14 |
+
if not self.base_url:
|
| 15 |
+
raise ValueError("RAG_MCP_URL environment variable is not set")
|
| 16 |
self.search_endpoint = f"{self.base_url}/search"
|
| 17 |
self.ingest_endpoint = f"{self.base_url}/ingest"
|
| 18 |
|
|
|
|
| 91 |
except Exception as e:
|
| 92 |
print("RAG List Error:", e)
|
| 93 |
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
| 94 |
+
|
| 95 |
+
async def delete_document(self, tenant_id: str, document_id: int):
|
| 96 |
+
"""
|
| 97 |
+
Delete a specific document by ID for a tenant.
|
| 98 |
+
"""
|
| 99 |
+
try:
|
| 100 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 101 |
+
response = await client.delete(
|
| 102 |
+
f"{self.base_url}/delete/{document_id}",
|
| 103 |
+
params={"tenant_id": tenant_id}
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if response.status_code == 404:
|
| 107 |
+
return {"error": f"Document {document_id} not found or access denied"}
|
| 108 |
+
if response.status_code != 200:
|
| 109 |
+
error_text = response.text
|
| 110 |
+
try:
|
| 111 |
+
error_json = response.json()
|
| 112 |
+
error_text = error_json.get("detail", error_text)
|
| 113 |
+
except:
|
| 114 |
+
pass
|
| 115 |
+
return {"error": f"HTTP {response.status_code}: {error_text}"}
|
| 116 |
+
|
| 117 |
+
data = response.json()
|
| 118 |
+
return data
|
| 119 |
+
|
| 120 |
+
except httpx.ConnectError as e:
|
| 121 |
+
print(f"RAG Delete Error: Cannot connect to RAG MCP server at {self.base_url}")
|
| 122 |
+
return {"error": f"Cannot connect to RAG MCP server. Is it running at {self.base_url}?"}
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"RAG Delete Error: {e}")
|
| 125 |
+
return {"error": str(e)}
|
| 126 |
+
|
| 127 |
+
async def delete_all_documents(self, tenant_id: str):
|
| 128 |
+
"""
|
| 129 |
+
Delete all documents for a tenant.
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 133 |
+
response = await client.delete(
|
| 134 |
+
f"{self.base_url}/delete-all",
|
| 135 |
+
params={"tenant_id": tenant_id}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
if response.status_code != 200:
|
| 139 |
+
error_text = response.text
|
| 140 |
+
try:
|
| 141 |
+
error_json = response.json()
|
| 142 |
+
error_text = error_json.get("detail", error_text)
|
| 143 |
+
except:
|
| 144 |
+
pass
|
| 145 |
+
return {"error": f"HTTP {response.status_code}: {error_text}"}
|
| 146 |
+
|
| 147 |
+
data = response.json()
|
| 148 |
+
return data
|
| 149 |
+
|
| 150 |
+
except httpx.ConnectError as e:
|
| 151 |
+
print(f"RAG Delete All Error: Cannot connect to RAG MCP server at {self.base_url}")
|
| 152 |
+
return {"error": f"Cannot connect to RAG MCP server. Is it running at {self.base_url}?"}
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(f"RAG Delete All Error: {e}")
|
| 155 |
+
return {"error": str(e)}
|
backend/api/routes/rag.py
CHANGED
|
@@ -220,3 +220,53 @@ async def rag_list(
|
|
| 220 |
return result
|
| 221 |
except Exception as e:
|
| 222 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
return result
|
| 221 |
except Exception as e:
|
| 222 |
raise HTTPException(status_code=500, detail=str(e))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.delete("/delete/{document_id}")
|
| 226 |
+
async def rag_delete(
|
| 227 |
+
document_id: int,
|
| 228 |
+
x_tenant_id: str = Header(None)
|
| 229 |
+
):
|
| 230 |
+
"""
|
| 231 |
+
Delete a specific document by ID from tenant knowledge base.
|
| 232 |
+
"""
|
| 233 |
+
if not x_tenant_id:
|
| 234 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
result = await rag_client.delete_document(x_tenant_id, document_id)
|
| 238 |
+
if "error" in result:
|
| 239 |
+
# Check if it's a connection error (500) or not found (404)
|
| 240 |
+
error_msg = result["error"]
|
| 241 |
+
if "Cannot connect" in error_msg:
|
| 242 |
+
raise HTTPException(status_code=503, detail=error_msg)
|
| 243 |
+
elif "not found" in error_msg.lower() or "access denied" in error_msg.lower():
|
| 244 |
+
raise HTTPException(status_code=404, detail=error_msg)
|
| 245 |
+
else:
|
| 246 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
| 247 |
+
return result
|
| 248 |
+
except HTTPException:
|
| 249 |
+
raise
|
| 250 |
+
except Exception as e:
|
| 251 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.delete("/delete-all")
|
| 255 |
+
async def rag_delete_all(
|
| 256 |
+
x_tenant_id: str = Header(None)
|
| 257 |
+
):
|
| 258 |
+
"""
|
| 259 |
+
Delete all documents for a tenant.
|
| 260 |
+
"""
|
| 261 |
+
if not x_tenant_id:
|
| 262 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 263 |
+
|
| 264 |
+
try:
|
| 265 |
+
result = await rag_client.delete_all_documents(x_tenant_id)
|
| 266 |
+
if "error" in result:
|
| 267 |
+
raise HTTPException(status_code=500, detail=result["error"])
|
| 268 |
+
return result
|
| 269 |
+
except HTTPException:
|
| 270 |
+
raise
|
| 271 |
+
except Exception as e:
|
| 272 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/mcp_servers/database.py
CHANGED
|
@@ -228,6 +228,65 @@ def list_all_documents(tenant_id: str, limit: int = 1000, offset: int = 0) -> Di
|
|
| 228 |
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
# -----------------------------------
|
| 232 |
# Supabase Client (for REST operations)
|
| 233 |
# -----------------------------------
|
|
|
|
| 228 |
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
| 229 |
|
| 230 |
|
| 231 |
+
def delete_document(tenant_id: str, document_id: int) -> bool:
|
| 232 |
+
"""
|
| 233 |
+
Delete a specific document by ID for a tenant.
|
| 234 |
+
Returns True if document was deleted, False otherwise.
|
| 235 |
+
"""
|
| 236 |
+
try:
|
| 237 |
+
conn = get_connection()
|
| 238 |
+
cur = conn.cursor()
|
| 239 |
+
|
| 240 |
+
# Delete the document (tenant_id check ensures tenant isolation)
|
| 241 |
+
cur.execute(
|
| 242 |
+
"""
|
| 243 |
+
DELETE FROM documents
|
| 244 |
+
WHERE id = %s AND tenant_id = %s;
|
| 245 |
+
""",
|
| 246 |
+
(document_id, tenant_id)
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
deleted = cur.rowcount > 0
|
| 250 |
+
conn.commit()
|
| 251 |
+
cur.close()
|
| 252 |
+
conn.close()
|
| 253 |
+
|
| 254 |
+
return deleted
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
print("DB DELETE ERROR:", e)
|
| 258 |
+
return False
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def delete_all_documents(tenant_id: str) -> int:
|
| 262 |
+
"""
|
| 263 |
+
Delete all documents for a tenant.
|
| 264 |
+
Returns the number of documents deleted.
|
| 265 |
+
"""
|
| 266 |
+
try:
|
| 267 |
+
conn = get_connection()
|
| 268 |
+
cur = conn.cursor()
|
| 269 |
+
|
| 270 |
+
cur.execute(
|
| 271 |
+
"""
|
| 272 |
+
DELETE FROM documents
|
| 273 |
+
WHERE tenant_id = %s;
|
| 274 |
+
""",
|
| 275 |
+
(tenant_id,)
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
deleted_count = cur.rowcount
|
| 279 |
+
conn.commit()
|
| 280 |
+
cur.close()
|
| 281 |
+
conn.close()
|
| 282 |
+
|
| 283 |
+
return deleted_count
|
| 284 |
+
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print("DB DELETE ALL ERROR:", e)
|
| 287 |
+
return 0
|
| 288 |
+
|
| 289 |
+
|
| 290 |
# -----------------------------------
|
| 291 |
# Supabase Client (for REST operations)
|
| 292 |
# -----------------------------------
|
backend/mcp_servers/main.py
CHANGED
|
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.join(parent_dir, "api")) # For utils
|
|
| 21 |
# --------------------------------------------------------
|
| 22 |
|
| 23 |
from embeddings import embed_text
|
| 24 |
-
from database import insert_document_chunks, search_vectors, list_all_documents, initialize_database
|
| 25 |
from utils.text_extractor import extract_text
|
| 26 |
|
| 27 |
|
|
@@ -168,6 +168,49 @@ def list_documents(tenant_id: str, limit: int = 1000, offset: int = 0):
|
|
| 168 |
raise HTTPException(status_code=500, detail=str(e))
|
| 169 |
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
# --------------------------------------------------------
|
| 172 |
# Allow "python main.py" to start server
|
| 173 |
# --------------------------------------------------------
|
|
|
|
| 21 |
# --------------------------------------------------------
|
| 22 |
|
| 23 |
from embeddings import embed_text
|
| 24 |
+
from database import insert_document_chunks, search_vectors, list_all_documents, initialize_database, delete_document, delete_all_documents
|
| 25 |
from utils.text_extractor import extract_text
|
| 26 |
|
| 27 |
|
|
|
|
| 168 |
raise HTTPException(status_code=500, detail=str(e))
|
| 169 |
|
| 170 |
|
| 171 |
+
# --------------------------------------------------------
|
| 172 |
+
# Delete Document Route
|
| 173 |
+
# --------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
@app.delete("/delete/{document_id}")
|
| 176 |
+
def delete_doc(document_id: int, tenant_id: str):
|
| 177 |
+
"""
|
| 178 |
+
Delete a specific document by ID for a tenant.
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
deleted = delete_document(tenant_id, document_id)
|
| 182 |
+
if not deleted:
|
| 183 |
+
raise HTTPException(status_code=404, detail="Document not found or access denied")
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"status": "ok",
|
| 187 |
+
"tenant_id": tenant_id,
|
| 188 |
+
"document_id": document_id,
|
| 189 |
+
"message": "Document deleted successfully"
|
| 190 |
+
}
|
| 191 |
+
except HTTPException:
|
| 192 |
+
raise
|
| 193 |
+
except Exception as e:
|
| 194 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.delete("/delete-all")
|
| 198 |
+
def delete_all_docs(tenant_id: str):
|
| 199 |
+
"""
|
| 200 |
+
Delete all documents for a tenant.
|
| 201 |
+
"""
|
| 202 |
+
try:
|
| 203 |
+
deleted_count = delete_all_documents(tenant_id)
|
| 204 |
+
return {
|
| 205 |
+
"status": "ok",
|
| 206 |
+
"tenant_id": tenant_id,
|
| 207 |
+
"deleted_count": deleted_count,
|
| 208 |
+
"message": f"Deleted {deleted_count} document(s)"
|
| 209 |
+
}
|
| 210 |
+
except Exception as e:
|
| 211 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
# --------------------------------------------------------
|
| 215 |
# Allow "python main.py" to start server
|
| 216 |
# --------------------------------------------------------
|
frontend/app/admin-rules/page.tsx
CHANGED
|
@@ -1,9 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import Link from "next/link";
|
| 2 |
|
| 3 |
import { AdminRulesPanel } from "@/components/admin-rules-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function AdminRulesPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
return (
|
| 8 |
<main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 9 |
<header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
|
|
@@ -14,9 +133,12 @@ export default function AdminRulesPage() {
|
|
| 14 |
</span>
|
| 15 |
IntegraChat · Admin Rule Ingestion
|
| 16 |
</div>
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
<p className="text-sm text-slate-300">
|
| 22 |
Push governance policies, compliance workflows, and red-flag patterns to the backend's persistent rules
|
|
@@ -25,6 +147,107 @@ export default function AdminRulesPage() {
|
|
| 25 |
</header>
|
| 26 |
|
| 27 |
<AdminRulesPanel />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
<Footer />
|
| 29 |
</main>
|
| 30 |
);
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useCallback, useMemo, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
|
| 6 |
import { AdminRulesPanel } from "@/components/admin-rules-panel";
|
| 7 |
import { Footer } from "@/components/footer";
|
| 8 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 9 |
+
import { TenantSelector } from "@/components/tenant-selector";
|
| 10 |
+
|
| 11 |
+
const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://localhost:8000";
|
| 12 |
+
|
| 13 |
+
type StatusState = { tone: "info" | "success" | "error"; message: string } | null;
|
| 14 |
|
| 15 |
export default function AdminRulesPage() {
|
| 16 |
+
const { tenantId } = useTenant();
|
| 17 |
+
const [rulesInput, setRulesInput] = useState("");
|
| 18 |
+
const [deleteInput, setDeleteInput] = useState("");
|
| 19 |
+
const [rules, setRules] = useState<string[]>([]);
|
| 20 |
+
const [loading, setLoading] = useState(false);
|
| 21 |
+
const [status, setStatus] = useState<StatusState>(null);
|
| 22 |
+
|
| 23 |
+
const headers = useMemo(() => {
|
| 24 |
+
if (!tenantId.trim()) return undefined;
|
| 25 |
+
return {
|
| 26 |
+
"Content-Type": "application/json",
|
| 27 |
+
"x-tenant-id": tenantId.trim(),
|
| 28 |
+
};
|
| 29 |
+
}, [tenantId]);
|
| 30 |
+
|
| 31 |
+
const requireTenant = useCallback(() => {
|
| 32 |
+
if (!tenantId.trim()) {
|
| 33 |
+
setStatus({ tone: "error", message: "Enter a tenant ID in the navbar first." });
|
| 34 |
+
return false;
|
| 35 |
+
}
|
| 36 |
+
return true;
|
| 37 |
+
}, [tenantId]);
|
| 38 |
+
|
| 39 |
+
const handleRefresh = useCallback(async () => {
|
| 40 |
+
if (!requireTenant()) return;
|
| 41 |
+
try {
|
| 42 |
+
setLoading(true);
|
| 43 |
+
setStatus({ tone: "info", message: "Loading rules..." });
|
| 44 |
+
const response = await fetch(`${BACKEND_BASE_URL}/admin/rules`, {
|
| 45 |
+
method: "GET",
|
| 46 |
+
headers,
|
| 47 |
+
});
|
| 48 |
+
if (!response.ok) {
|
| 49 |
+
throw new Error(`Backend error ${response.status}`);
|
| 50 |
+
}
|
| 51 |
+
const data = await response.json();
|
| 52 |
+
setRules(data.rules ?? []);
|
| 53 |
+
setStatus({ tone: "success", message: "Rules synced." });
|
| 54 |
+
} catch (error: any) {
|
| 55 |
+
setStatus({ tone: "error", message: error.message || "Failed to fetch rules" });
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false);
|
| 58 |
+
}
|
| 59 |
+
}, [headers, requireTenant]);
|
| 60 |
+
|
| 61 |
+
const handleUpload = useCallback(async () => {
|
| 62 |
+
if (!requireTenant()) return;
|
| 63 |
+
const lines = rulesInput
|
| 64 |
+
.split("\n")
|
| 65 |
+
.map((line) => line.trim())
|
| 66 |
+
.filter(Boolean);
|
| 67 |
+
|
| 68 |
+
if (!lines.length) {
|
| 69 |
+
setStatus({ tone: "error", message: "Add at least one rule to upload." });
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
setLoading(true);
|
| 75 |
+
setStatus({ tone: "info", message: "Uploading rules..." });
|
| 76 |
+
const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk`, {
|
| 77 |
+
method: "POST",
|
| 78 |
+
headers,
|
| 79 |
+
body: JSON.stringify({ rules: lines }),
|
| 80 |
+
});
|
| 81 |
+
if (!response.ok) {
|
| 82 |
+
const details = await response.text();
|
| 83 |
+
throw new Error(details || `Backend error ${response.status}`);
|
| 84 |
+
}
|
| 85 |
+
await handleRefresh();
|
| 86 |
+
setRulesInput("");
|
| 87 |
+
setStatus({ tone: "success", message: "Rules uploaded successfully." });
|
| 88 |
+
} catch (error: any) {
|
| 89 |
+
setStatus({ tone: "error", message: error.message || "Failed to upload rules" });
|
| 90 |
+
} finally {
|
| 91 |
+
setLoading(false);
|
| 92 |
+
}
|
| 93 |
+
}, [handleRefresh, headers, requireTenant, rulesInput]);
|
| 94 |
+
|
| 95 |
+
const handleDelete = useCallback(async () => {
|
| 96 |
+
if (!requireTenant()) return;
|
| 97 |
+
if (!deleteInput.trim()) {
|
| 98 |
+
setStatus({ tone: "error", message: "Enter the rule text you want to delete." });
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
try {
|
| 103 |
+
setLoading(true);
|
| 104 |
+
setStatus({ tone: "info", message: "Deleting rule..." });
|
| 105 |
+
const response = await fetch(
|
| 106 |
+
`${BACKEND_BASE_URL}/admin/rules/${encodeURIComponent(deleteInput.trim())}`,
|
| 107 |
+
{
|
| 108 |
+
method: "DELETE",
|
| 109 |
+
headers,
|
| 110 |
+
}
|
| 111 |
+
);
|
| 112 |
+
if (!response.ok) {
|
| 113 |
+
const details = await response.text();
|
| 114 |
+
throw new Error(details || `Backend error ${response.status}`);
|
| 115 |
+
}
|
| 116 |
+
await handleRefresh();
|
| 117 |
+
setDeleteInput("");
|
| 118 |
+
setStatus({ tone: "success", message: "Rule deleted." });
|
| 119 |
+
} catch (error: any) {
|
| 120 |
+
setStatus({ tone: "error", message: error.message || "Failed to delete rule" });
|
| 121 |
+
} finally {
|
| 122 |
+
setLoading(false);
|
| 123 |
+
}
|
| 124 |
+
}, [deleteInput, handleRefresh, headers, requireTenant]);
|
| 125 |
+
|
| 126 |
return (
|
| 127 |
<main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 128 |
<header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
|
|
|
|
| 133 |
</span>
|
| 134 |
IntegraChat · Admin Rule Ingestion
|
| 135 |
</div>
|
| 136 |
+
<div className="flex items-center gap-4">
|
| 137 |
+
<TenantSelector />
|
| 138 |
+
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
|
| 139 |
+
← Back Home
|
| 140 |
+
</Link>
|
| 141 |
+
</div>
|
| 142 |
</div>
|
| 143 |
<p className="text-sm text-slate-300">
|
| 144 |
Push governance policies, compliance workflows, and red-flag patterns to the backend's persistent rules
|
|
|
|
| 147 |
</header>
|
| 148 |
|
| 149 |
<AdminRulesPanel />
|
| 150 |
+
|
| 151 |
+
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-2xl shadow-slate-950/40">
|
| 152 |
+
<div className="flex flex-col gap-6">
|
| 153 |
+
<div className="flex items-center justify-end gap-3">
|
| 154 |
+
<button
|
| 155 |
+
onClick={handleRefresh}
|
| 156 |
+
disabled={loading}
|
| 157 |
+
className="flex-1 rounded-full bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 disabled:opacity-60"
|
| 158 |
+
>
|
| 159 |
+
Refresh Rules
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="grid gap-4 lg:grid-cols-2">
|
| 164 |
+
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-200">
|
| 165 |
+
Bulk Upload Rules (one per line)
|
| 166 |
+
<textarea
|
| 167 |
+
value={rulesInput}
|
| 168 |
+
onChange={(e) => setRulesInput(e.target.value)}
|
| 169 |
+
placeholder="Disallow sharing salaries\nBlock deleting production data"
|
| 170 |
+
rows={6}
|
| 171 |
+
className="rounded-2xl border border-white/10 bg-slate-900/50 px-4 py-3 text-base text-white outline-none ring-0 focus:border-cyan-400"
|
| 172 |
+
/>
|
| 173 |
+
</label>
|
| 174 |
+
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-200">
|
| 175 |
+
Delete Rule (exact text match)
|
| 176 |
+
<textarea
|
| 177 |
+
value={deleteInput}
|
| 178 |
+
onChange={(e) => setDeleteInput(e.target.value)}
|
| 179 |
+
placeholder="Enter the exact rule text to remove..."
|
| 180 |
+
rows={6}
|
| 181 |
+
className="rounded-2xl border border-white/10 bg-slate-900/50 px-4 py-3 text-base text-white outline-none ring-0 focus:border-cyan-400"
|
| 182 |
+
/>
|
| 183 |
+
</label>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div className="flex flex-wrap gap-3">
|
| 187 |
+
<button
|
| 188 |
+
onClick={handleUpload}
|
| 189 |
+
disabled={loading}
|
| 190 |
+
className="rounded-full bg-gradient-to-r from-emerald-400 to-lime-400 px-6 py-3 text-sm font-semibold text-slate-900 shadow-lg shadow-emerald-500/30 disabled:opacity-60"
|
| 191 |
+
>
|
| 192 |
+
Upload / Append Rules
|
| 193 |
+
</button>
|
| 194 |
+
<button
|
| 195 |
+
onClick={handleDelete}
|
| 196 |
+
disabled={loading}
|
| 197 |
+
className="rounded-full border border-rose-500 px-6 py-3 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/10 disabled:opacity-60"
|
| 198 |
+
>
|
| 199 |
+
Delete Rule
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{status && (
|
| 204 |
+
<div
|
| 205 |
+
className={`rounded-2xl border px-4 py-3 text-sm ${
|
| 206 |
+
status.tone === "error"
|
| 207 |
+
? "border-rose-500/40 bg-rose-500/10 text-rose-200"
|
| 208 |
+
: status.tone === "success"
|
| 209 |
+
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
| 210 |
+
: "border-cyan-500/40 bg-cyan-500/10 text-cyan-200"
|
| 211 |
+
}`}
|
| 212 |
+
>
|
| 213 |
+
{status.message}
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
|
| 217 |
+
<div className="rounded-2xl border border-white/10 bg-slate-950/40">
|
| 218 |
+
<div className="flex items-center justify-between border-b border-white/5 px-5 py-3 text-xs uppercase tracking-[0.3em] text-slate-400">
|
| 219 |
+
<span>Rule Set</span>
|
| 220 |
+
<span>{rules.length} entries</span>
|
| 221 |
+
</div>
|
| 222 |
+
<div className="overflow-x-auto">
|
| 223 |
+
<table className="w-full text-left text-sm text-slate-200">
|
| 224 |
+
<thead className="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-400">
|
| 225 |
+
<tr>
|
| 226 |
+
<th className="px-4 py-3">#</th>
|
| 227 |
+
<th className="px-4 py-3">Rule</th>
|
| 228 |
+
</tr>
|
| 229 |
+
</thead>
|
| 230 |
+
<tbody>
|
| 231 |
+
{rules.length === 0 && (
|
| 232 |
+
<tr>
|
| 233 |
+
<td colSpan={2} className="px-4 py-6 text-center text-slate-500">
|
| 234 |
+
No rules loaded. Use the refresh button above.
|
| 235 |
+
</td>
|
| 236 |
+
</tr>
|
| 237 |
+
)}
|
| 238 |
+
{rules.map((rule, idx) => (
|
| 239 |
+
<tr key={`${rule}-${idx}`} className="border-t border-white/5">
|
| 240 |
+
<td className="px-4 py-3 text-slate-400">{idx + 1}</td>
|
| 241 |
+
<td className="px-4 py-3">{rule}</td>
|
| 242 |
+
</tr>
|
| 243 |
+
))}
|
| 244 |
+
</tbody>
|
| 245 |
+
</table>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</section>
|
| 250 |
+
|
| 251 |
<Footer />
|
| 252 |
</main>
|
| 253 |
);
|
frontend/app/analytics/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|
| 2 |
|
| 3 |
import { AnalyticsPanel } from "@/components/analytics-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
|
|
|
| 5 |
|
| 6 |
export default function AnalyticsPage() {
|
| 7 |
return (
|
|
@@ -14,9 +15,12 @@ export default function AnalyticsPage() {
|
|
| 14 |
</span>
|
| 15 |
IntegraChat · Analytics
|
| 16 |
</div>
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
<p className="text-sm text-slate-300">
|
| 22 |
Inspect tenant-wide metrics including tool usage, red-flag violations, and overall activity—all powered by the
|
|
|
|
| 2 |
|
| 3 |
import { AnalyticsPanel } from "@/components/analytics-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
| 5 |
+
import { TenantSelector } from "@/components/tenant-selector";
|
| 6 |
|
| 7 |
export default function AnalyticsPage() {
|
| 8 |
return (
|
|
|
|
| 15 |
</span>
|
| 16 |
IntegraChat · Analytics
|
| 17 |
</div>
|
| 18 |
+
<div className="flex items-center gap-4">
|
| 19 |
+
<TenantSelector />
|
| 20 |
+
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
|
| 21 |
+
← Back Home
|
| 22 |
+
</Link>
|
| 23 |
+
</div>
|
| 24 |
</div>
|
| 25 |
<p className="text-sm text-slate-300">
|
| 26 |
Inspect tenant-wide metrics including tool usage, red-flag violations, and overall activity—all powered by the
|
frontend/app/chat/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|
| 2 |
|
| 3 |
import { ChatPanel } from "@/components/chat-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
|
|
|
| 5 |
|
| 6 |
export default function ChatPage() {
|
| 7 |
return (
|
|
@@ -14,9 +15,12 @@ export default function ChatPage() {
|
|
| 14 |
</span>
|
| 15 |
IntegraChat · Chat Bot
|
| 16 |
</div>
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
<p className="text-sm text-slate-300">
|
| 22 |
Experience the MCP agent orchestration layer with multi-tool reasoning, tenant isolation, and red-flag aware
|
|
|
|
| 2 |
|
| 3 |
import { ChatPanel } from "@/components/chat-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
| 5 |
+
import { TenantSelector } from "@/components/tenant-selector";
|
| 6 |
|
| 7 |
export default function ChatPage() {
|
| 8 |
return (
|
|
|
|
| 15 |
</span>
|
| 16 |
IntegraChat · Chat Bot
|
| 17 |
</div>
|
| 18 |
+
<div className="flex items-center gap-4">
|
| 19 |
+
<TenantSelector />
|
| 20 |
+
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
|
| 21 |
+
← Back Home
|
| 22 |
+
</Link>
|
| 23 |
+
</div>
|
| 24 |
</div>
|
| 25 |
<p className="text-sm text-slate-300">
|
| 26 |
Experience the MCP agent orchestration layer with multi-tool reasoning, tenant isolation, and red-flag aware
|
frontend/app/ingestion/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|
| 2 |
|
| 3 |
import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
|
|
|
| 5 |
|
| 6 |
export default function IngestionPage() {
|
| 7 |
return (
|
|
@@ -14,9 +15,12 @@ export default function IngestionPage() {
|
|
| 14 |
</span>
|
| 15 |
IntegraChat · Data Ingestion
|
| 16 |
</div>
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
<p className="text-sm text-slate-300">
|
| 22 |
Upload raw text, URLs, or documents to feed the tenant-specific RAG index. All inputs flow into the FastAPI +
|
|
|
|
| 2 |
|
| 3 |
import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
|
| 4 |
import { Footer } from "@/components/footer";
|
| 5 |
+
import { TenantSelector } from "@/components/tenant-selector";
|
| 6 |
|
| 7 |
export default function IngestionPage() {
|
| 8 |
return (
|
|
|
|
| 15 |
</span>
|
| 16 |
IntegraChat · Data Ingestion
|
| 17 |
</div>
|
| 18 |
+
<div className="flex items-center gap-4">
|
| 19 |
+
<TenantSelector />
|
| 20 |
+
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
|
| 21 |
+
← Back Home
|
| 22 |
+
</Link>
|
| 23 |
+
</div>
|
| 24 |
</div>
|
| 25 |
<p className="text-sm text-slate-300">
|
| 26 |
Upload raw text, URLs, or documents to feed the tenant-specific RAG index. All inputs flow into the FastAPI +
|
frontend/app/knowledge-base/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from "react";
|
| 4 |
import Link from "next/link";
|
|
|
|
| 5 |
|
| 6 |
type Document = {
|
| 7 |
id: number;
|
|
@@ -20,15 +21,24 @@ const API_BASE =
|
|
| 20 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 21 |
|
| 22 |
export default function KnowledgeBasePage() {
|
| 23 |
-
const
|
| 24 |
const [documents, setDocuments] = useState<Document[]>([]);
|
| 25 |
const [total, setTotal] = useState(0);
|
| 26 |
const [loading, setLoading] = useState(false);
|
| 27 |
const [error, setError] = useState<string | null>(null);
|
| 28 |
const [searchFilter, setSearchFilter] = useState("");
|
| 29 |
const [filterType, setFilterType] = useState<"all" | "pdf" | "text" | "faq" | "link">("all");
|
|
|
|
|
|
|
| 30 |
|
| 31 |
async function loadDocuments() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
setLoading(true);
|
| 33 |
setError(null);
|
| 34 |
|
|
@@ -43,7 +53,18 @@ export default function KnowledgeBasePage() {
|
|
| 43 |
);
|
| 44 |
|
| 45 |
if (!response.ok) {
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
const data: DocumentListResponse = await response.json();
|
|
@@ -54,7 +75,7 @@ export default function KnowledgeBasePage() {
|
|
| 54 |
setError(
|
| 55 |
err instanceof Error
|
| 56 |
? err.message
|
| 57 |
-
: "Failed to load knowledge base.
|
| 58 |
);
|
| 59 |
} finally {
|
| 60 |
setLoading(false);
|
|
@@ -62,8 +83,11 @@ export default function KnowledgeBasePage() {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
useEffect(() => {
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
// Filter documents based on search and type
|
| 69 |
const filteredDocuments = documents.filter((doc) => {
|
|
@@ -122,6 +146,75 @@ export default function KnowledgeBasePage() {
|
|
| 122 |
}
|
| 123 |
};
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return (
|
| 126 |
<main className="mx-auto flex min-h-screen max-w-7xl flex-col gap-8 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 127 |
{/* Header */}
|
|
@@ -141,12 +234,15 @@ export default function KnowledgeBasePage() {
|
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
<div className="flex items-center gap-3">
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
<button
|
| 151 |
onClick={loadDocuments}
|
| 152 |
disabled={loading}
|
|
@@ -205,8 +301,17 @@ export default function KnowledgeBasePage() {
|
|
| 205 |
{/* Error Message */}
|
| 206 |
{error && (
|
| 207 |
<div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-6 py-4 text-red-200">
|
| 208 |
-
<p className="font-semibold"
|
| 209 |
<p className="mt-1 text-sm">{error}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
</div>
|
| 211 |
)}
|
| 212 |
|
|
@@ -254,10 +359,19 @@ export default function KnowledgeBasePage() {
|
|
| 254 |
<p className="text-sm leading-relaxed text-slate-200 line-clamp-6">
|
| 255 |
{preview}
|
| 256 |
</p>
|
| 257 |
-
<div className="mt-4 flex items-center
|
| 258 |
-
<
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
);
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 6 |
|
| 7 |
type Document = {
|
| 8 |
id: number;
|
|
|
|
| 21 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 22 |
|
| 23 |
export default function KnowledgeBasePage() {
|
| 24 |
+
const { tenantId, isLoading: tenantLoading } = useTenant();
|
| 25 |
const [documents, setDocuments] = useState<Document[]>([]);
|
| 26 |
const [total, setTotal] = useState(0);
|
| 27 |
const [loading, setLoading] = useState(false);
|
| 28 |
const [error, setError] = useState<string | null>(null);
|
| 29 |
const [searchFilter, setSearchFilter] = useState("");
|
| 30 |
const [filterType, setFilterType] = useState<"all" | "pdf" | "text" | "faq" | "link">("all");
|
| 31 |
+
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
| 32 |
+
const [isDeletingAll, setIsDeletingAll] = useState(false);
|
| 33 |
|
| 34 |
async function loadDocuments() {
|
| 35 |
+
// Guard against empty tenant ID
|
| 36 |
+
if (!tenantId || !tenantId.trim()) {
|
| 37 |
+
setError("Please enter a tenant ID");
|
| 38 |
+
setLoading(false);
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
setLoading(true);
|
| 43 |
setError(null);
|
| 44 |
|
|
|
|
| 53 |
);
|
| 54 |
|
| 55 |
if (!response.ok) {
|
| 56 |
+
const errorData = await response.json().catch(() => ({}));
|
| 57 |
+
const errorMsg = errorData.detail || errorData.message || `Failed to load documents (${response.status})`;
|
| 58 |
+
|
| 59 |
+
if (response.status === 400) {
|
| 60 |
+
throw new Error(errorMsg.includes("tenant")
|
| 61 |
+
? "Missing tenant ID. Please enter a tenant ID in the navbar."
|
| 62 |
+
: errorMsg);
|
| 63 |
+
} else if (response.status === 503) {
|
| 64 |
+
throw new Error("Cannot connect to RAG MCP server. Please ensure the RAG server is running.");
|
| 65 |
+
} else {
|
| 66 |
+
throw new Error(errorMsg);
|
| 67 |
+
}
|
| 68 |
}
|
| 69 |
|
| 70 |
const data: DocumentListResponse = await response.json();
|
|
|
|
| 75 |
setError(
|
| 76 |
err instanceof Error
|
| 77 |
? err.message
|
| 78 |
+
: "Failed to load knowledge base. Please check if the backend services are running.",
|
| 79 |
);
|
| 80 |
} finally {
|
| 81 |
setLoading(false);
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
useEffect(() => {
|
| 86 |
+
// Wait for tenant context to finish loading, then load documents if tenant ID is available
|
| 87 |
+
if (!tenantLoading && tenantId && tenantId.trim()) {
|
| 88 |
+
loadDocuments();
|
| 89 |
+
}
|
| 90 |
+
}, [tenantId, tenantLoading]);
|
| 91 |
|
| 92 |
// Filter documents based on search and type
|
| 93 |
const filteredDocuments = documents.filter((doc) => {
|
|
|
|
| 146 |
}
|
| 147 |
};
|
| 148 |
|
| 149 |
+
async function handleDeleteDocument(documentId: number) {
|
| 150 |
+
if (!tenantId.trim() || isDeleting !== null) return;
|
| 151 |
+
setIsDeleting(documentId);
|
| 152 |
+
|
| 153 |
+
try {
|
| 154 |
+
const response = await fetch(`${API_BASE}/rag/delete/${documentId}`, {
|
| 155 |
+
method: "DELETE",
|
| 156 |
+
headers: {
|
| 157 |
+
"x-tenant-id": tenantId,
|
| 158 |
+
},
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
if (!response.ok) {
|
| 162 |
+
const errorData = await response.json().catch(() => ({}));
|
| 163 |
+
const errorMsg = errorData.detail || `Failed to delete document: ${response.status}`;
|
| 164 |
+
throw new Error(errorMsg);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Remove from local state and update total
|
| 168 |
+
setDocuments(docs => docs.filter(doc => doc.id !== documentId));
|
| 169 |
+
setTotal(prev => Math.max(0, prev - 1));
|
| 170 |
+
} catch (err) {
|
| 171 |
+
console.error(err);
|
| 172 |
+
setError(
|
| 173 |
+
err instanceof Error
|
| 174 |
+
? err.message
|
| 175 |
+
: "Failed to delete document",
|
| 176 |
+
);
|
| 177 |
+
} finally {
|
| 178 |
+
setIsDeleting(null);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
async function handleDeleteAll() {
|
| 183 |
+
if (!tenantId.trim() || isDeletingAll) return;
|
| 184 |
+
|
| 185 |
+
if (!confirm("Are you sure you want to delete ALL documents for this tenant? This action cannot be undone.")) {
|
| 186 |
+
return;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
setIsDeletingAll(true);
|
| 190 |
+
|
| 191 |
+
try {
|
| 192 |
+
const response = await fetch(`${API_BASE}/rag/delete-all`, {
|
| 193 |
+
method: "DELETE",
|
| 194 |
+
headers: {
|
| 195 |
+
"x-tenant-id": tenantId,
|
| 196 |
+
},
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
if (!response.ok) {
|
| 200 |
+
throw new Error(`Failed to delete all documents: ${response.status}`);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const data = await response.json();
|
| 204 |
+
setDocuments([]);
|
| 205 |
+
setTotal(0);
|
| 206 |
+
} catch (err) {
|
| 207 |
+
console.error(err);
|
| 208 |
+
setError(
|
| 209 |
+
err instanceof Error
|
| 210 |
+
? err.message
|
| 211 |
+
: "Failed to delete all documents",
|
| 212 |
+
);
|
| 213 |
+
} finally {
|
| 214 |
+
setIsDeletingAll(false);
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
return (
|
| 219 |
<main className="mx-auto flex min-h-screen max-w-7xl flex-col gap-8 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 220 |
{/* Header */}
|
|
|
|
| 234 |
</div>
|
| 235 |
</div>
|
| 236 |
<div className="flex items-center gap-3">
|
| 237 |
+
{documents.length > 0 && (
|
| 238 |
+
<button
|
| 239 |
+
onClick={handleDeleteAll}
|
| 240 |
+
disabled={isDeletingAll}
|
| 241 |
+
className="rounded-full border border-red-500/50 bg-red-500/10 px-5 py-2 text-sm font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
|
| 242 |
+
>
|
| 243 |
+
{isDeletingAll ? "Deleting…" : "Delete All"}
|
| 244 |
+
</button>
|
| 245 |
+
)}
|
| 246 |
<button
|
| 247 |
onClick={loadDocuments}
|
| 248 |
disabled={loading}
|
|
|
|
| 301 |
{/* Error Message */}
|
| 302 |
{error && (
|
| 303 |
<div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-6 py-4 text-red-200">
|
| 304 |
+
<p className="font-semibold">⚠️ Error loading knowledge base</p>
|
| 305 |
<p className="mt-1 text-sm">{error}</p>
|
| 306 |
+
<button
|
| 307 |
+
onClick={() => {
|
| 308 |
+
setError(null);
|
| 309 |
+
loadDocuments();
|
| 310 |
+
}}
|
| 311 |
+
className="mt-3 rounded-lg border border-red-500/50 bg-red-500/20 px-4 py-2 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
|
| 312 |
+
>
|
| 313 |
+
Try Again
|
| 314 |
+
</button>
|
| 315 |
</div>
|
| 316 |
)}
|
| 317 |
|
|
|
|
| 359 |
<p className="text-sm leading-relaxed text-slate-200 line-clamp-6">
|
| 360 |
{preview}
|
| 361 |
</p>
|
| 362 |
+
<div className="mt-4 flex items-center justify-between">
|
| 363 |
+
<div className="flex items-center gap-2 text-xs text-slate-400">
|
| 364 |
+
<span>ID: {doc.id}</span>
|
| 365 |
+
<span>•</span>
|
| 366 |
+
<span>{doc.text.length} chars</span>
|
| 367 |
+
</div>
|
| 368 |
+
<button
|
| 369 |
+
onClick={() => handleDeleteDocument(doc.id)}
|
| 370 |
+
disabled={isDeleting === doc.id}
|
| 371 |
+
className="rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
|
| 372 |
+
>
|
| 373 |
+
{isDeleting === doc.id ? "Deleting…" : "Delete"}
|
| 374 |
+
</button>
|
| 375 |
</div>
|
| 376 |
</div>
|
| 377 |
);
|
frontend/app/layout.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
|
|
|
| 4 |
|
| 5 |
const geistSans = Geist({
|
| 6 |
variable: "--font-geist-sans",
|
|
@@ -28,7 +29,9 @@ export default function RootLayout({
|
|
| 28 |
<body
|
| 29 |
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 30 |
>
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
</body>
|
| 33 |
</html>
|
| 34 |
);
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
+
import { TenantProvider } from "@/contexts/TenantContext";
|
| 5 |
|
| 6 |
const geistSans = Geist({
|
| 7 |
variable: "--font-geist-sans",
|
|
|
|
| 29 |
<body
|
| 30 |
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 31 |
>
|
| 32 |
+
<TenantProvider>
|
| 33 |
+
{children}
|
| 34 |
+
</TenantProvider>
|
| 35 |
</body>
|
| 36 |
</html>
|
| 37 |
);
|
frontend/app/page.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { FeatureGrid } from "@/components/feature-grid";
|
|
| 7 |
import { Footer } from "@/components/footer";
|
| 8 |
import { Hero } from "@/components/hero";
|
| 9 |
import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
|
|
|
|
| 10 |
|
| 11 |
const navItems = [
|
| 12 |
{ label: "Data Ingestion", href: "/ingestion" },
|
|
@@ -20,42 +21,45 @@ export default function Home() {
|
|
| 20 |
<main className="mx-auto flex min-h-screen max-w-6xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 21 |
<header className="flex flex-col gap-6 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
|
| 22 |
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
</div>
|
| 33 |
-
<nav className="flex flex-wrap gap-2">
|
| 34 |
-
{navItems.map((item) => (
|
| 35 |
-
<Link
|
| 36 |
-
key={item.href}
|
| 37 |
-
href={item.href}
|
| 38 |
-
className="rounded-full border border-white/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400 hover:text-white"
|
| 39 |
-
>
|
| 40 |
-
{item.label}
|
| 41 |
-
</Link>
|
| 42 |
-
))}
|
| 43 |
-
</nav>
|
| 44 |
</header>
|
| 45 |
|
| 46 |
<Hero />
|
| 47 |
<FeatureGrid />
|
| 48 |
|
| 49 |
<section id="data-ingestion" className="scroll-mt-28">
|
| 50 |
-
|
| 51 |
</section>
|
| 52 |
|
| 53 |
<section id="chat-bot" className="scroll-mt-28">
|
| 54 |
-
|
| 55 |
</section>
|
| 56 |
|
| 57 |
<section id="analytics" className="scroll-mt-28">
|
| 58 |
-
|
| 59 |
</section>
|
| 60 |
|
| 61 |
<section id="admin-rules" className="scroll-mt-28">
|
|
|
|
| 7 |
import { Footer } from "@/components/footer";
|
| 8 |
import { Hero } from "@/components/hero";
|
| 9 |
import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
|
| 10 |
+
import { TenantSelector } from "@/components/tenant-selector";
|
| 11 |
|
| 12 |
const navItems = [
|
| 13 |
{ label: "Data Ingestion", href: "/ingestion" },
|
|
|
|
| 21 |
<main className="mx-auto flex min-h-screen max-w-6xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 22 |
<header className="flex flex-col gap-6 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
|
| 23 |
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
| 24 |
+
<div className="flex items-center gap-3 text-base font-semibold">
|
| 25 |
+
<span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
|
| 26 |
+
IC
|
| 27 |
+
</span>
|
| 28 |
+
IntegraChat Operator Console
|
| 29 |
+
</div>
|
| 30 |
+
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.4em] text-slate-400">
|
| 31 |
+
FastAPI · MCP Servers · Celery · Next.js
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
| 35 |
+
<nav className="flex flex-wrap gap-2">
|
| 36 |
+
{navItems.map((item) => (
|
| 37 |
+
<Link
|
| 38 |
+
key={item.href}
|
| 39 |
+
href={item.href}
|
| 40 |
+
className="rounded-full border border-white/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400 hover:text-white"
|
| 41 |
+
>
|
| 42 |
+
{item.label}
|
| 43 |
+
</Link>
|
| 44 |
+
))}
|
| 45 |
+
</nav>
|
| 46 |
+
<TenantSelector />
|
| 47 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</header>
|
| 49 |
|
| 50 |
<Hero />
|
| 51 |
<FeatureGrid />
|
| 52 |
|
| 53 |
<section id="data-ingestion" className="scroll-mt-28">
|
| 54 |
+
<KnowledgeBasePanel />
|
| 55 |
</section>
|
| 56 |
|
| 57 |
<section id="chat-bot" className="scroll-mt-28">
|
| 58 |
+
<ChatPanel />
|
| 59 |
</section>
|
| 60 |
|
| 61 |
<section id="analytics" className="scroll-mt-28">
|
| 62 |
+
<AnalyticsPanel />
|
| 63 |
</section>
|
| 64 |
|
| 65 |
<section id="admin-rules" className="scroll-mt-28">
|
frontend/components/analytics-panel.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState } from "react";
|
|
|
|
| 4 |
|
| 5 |
type AnalyticsOverview = {
|
| 6 |
overview: {
|
|
@@ -15,7 +16,7 @@ const API_BASE =
|
|
| 15 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 16 |
|
| 17 |
export function AnalyticsPanel() {
|
| 18 |
-
const
|
| 19 |
const [loading, setLoading] = useState(false);
|
| 20 |
const [error, setError] = useState<string | null>(null);
|
| 21 |
const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
|
|
@@ -58,16 +59,6 @@ export function AnalyticsPanel() {
|
|
| 58 |
</p>
|
| 59 |
<h2 className="mt-2 text-3xl font-semibold">Analytics snapshot</h2>
|
| 60 |
</div>
|
| 61 |
-
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 62 |
-
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 63 |
-
Tenant ID
|
| 64 |
-
</label>
|
| 65 |
-
<input
|
| 66 |
-
value={tenantId}
|
| 67 |
-
onChange={(e) => setTenantId(e.target.value)}
|
| 68 |
-
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 69 |
-
/>
|
| 70 |
-
</div>
|
| 71 |
<button
|
| 72 |
onClick={fetchAnalytics}
|
| 73 |
disabled={loading}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState } from "react";
|
| 4 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 5 |
|
| 6 |
type AnalyticsOverview = {
|
| 7 |
overview: {
|
|
|
|
| 16 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 17 |
|
| 18 |
export function AnalyticsPanel() {
|
| 19 |
+
const { tenantId } = useTenant();
|
| 20 |
const [loading, setLoading] = useState(false);
|
| 21 |
const [error, setError] = useState<string | null>(null);
|
| 22 |
const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
|
|
|
|
| 59 |
</p>
|
| 60 |
<h2 className="mt-2 text-3xl font-semibold">Analytics snapshot</h2>
|
| 61 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
<button
|
| 63 |
onClick={fetchAnalytics}
|
| 64 |
disabled={loading}
|
frontend/components/chat-panel.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useMemo, useState } from "react";
|
|
|
|
| 4 |
|
| 5 |
type Message = {
|
| 6 |
role: "user" | "assistant" | "system";
|
|
@@ -12,7 +13,7 @@ const API_BASE =
|
|
| 12 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 13 |
|
| 14 |
export function ChatPanel() {
|
| 15 |
-
const
|
| 16 |
const [message, setMessage] = useState("");
|
| 17 |
const [isSending, setIsSending] = useState(false);
|
| 18 |
const [history, setHistory] = useState<Message[]>([
|
|
@@ -106,25 +107,13 @@ export function ChatPanel() {
|
|
| 106 |
className="gradient-border relative rounded-[28px] p-1 text-white"
|
| 107 |
>
|
| 108 |
<div className="glass-panel relative rounded-[26px] p-6">
|
| 109 |
-
<div
|
| 110 |
-
<
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
</h2>
|
| 117 |
-
</div>
|
| 118 |
-
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 119 |
-
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 120 |
-
Tenant ID
|
| 121 |
-
</label>
|
| 122 |
-
<input
|
| 123 |
-
value={tenantId}
|
| 124 |
-
onChange={(e) => setTenantId(e.target.value)}
|
| 125 |
-
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 126 |
-
/>
|
| 127 |
-
</div>
|
| 128 |
</div>
|
| 129 |
<div className="mt-6 h-[360px] space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/40 p-4 scrollArea">
|
| 130 |
{history.map((msg, idx) => (
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useMemo, useState } from "react";
|
| 4 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 5 |
|
| 6 |
type Message = {
|
| 7 |
role: "user" | "assistant" | "system";
|
|
|
|
| 13 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 14 |
|
| 15 |
export function ChatPanel() {
|
| 16 |
+
const { tenantId } = useTenant();
|
| 17 |
const [message, setMessage] = useState("");
|
| 18 |
const [isSending, setIsSending] = useState(false);
|
| 19 |
const [history, setHistory] = useState<Message[]>([
|
|
|
|
| 107 |
className="gradient-border relative rounded-[28px] p-1 text-white"
|
| 108 |
>
|
| 109 |
<div className="glass-panel relative rounded-[26px] p-6">
|
| 110 |
+
<div>
|
| 111 |
+
<p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
|
| 112 |
+
Orchestrator Console
|
| 113 |
+
</p>
|
| 114 |
+
<h2 className="mt-2 text-3xl font-semibold">
|
| 115 |
+
Talk to your enterprise agent
|
| 116 |
+
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
| 118 |
<div className="mt-6 h-[360px] space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/40 p-4 scrollArea">
|
| 119 |
{history.map((msg, idx) => (
|
frontend/components/knowledge-base-panel.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState, useRef } from "react";
|
| 4 |
import Link from "next/link";
|
|
|
|
| 5 |
|
| 6 |
type SearchResult = {
|
| 7 |
text: string;
|
|
@@ -9,13 +10,19 @@ type SearchResult = {
|
|
| 9 |
relevance?: number;
|
| 10 |
};
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
type SourceType = "raw_text" | "url" | "pdf" | "docx" | "txt" | "markdown";
|
| 13 |
|
| 14 |
const API_BASE =
|
| 15 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 16 |
|
| 17 |
export function KnowledgeBasePanel() {
|
| 18 |
-
const
|
| 19 |
const [searchQuery, setSearchQuery] = useState("");
|
| 20 |
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
| 21 |
const [isSearching, setIsSearching] = useState(false);
|
|
@@ -26,6 +33,10 @@ export function KnowledgeBasePanel() {
|
|
| 26 |
const [isIngesting, setIsIngesting] = useState(false);
|
| 27 |
const [ingestStatus, setIngestStatus] = useState<string | null>(null);
|
| 28 |
const [searchError, setSearchError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 30 |
|
| 31 |
async function handleSearch() {
|
|
@@ -123,6 +134,8 @@ export function KnowledgeBasePanel() {
|
|
| 123 |
if (fileInputRef.current) {
|
| 124 |
fileInputRef.current.value = "";
|
| 125 |
}
|
|
|
|
|
|
|
| 126 |
} catch (err) {
|
| 127 |
console.error(err);
|
| 128 |
setIngestStatus(
|
|
@@ -186,6 +199,8 @@ export function KnowledgeBasePanel() {
|
|
| 186 |
if (fileInputRef.current) {
|
| 187 |
fileInputRef.current.value = "";
|
| 188 |
}
|
|
|
|
|
|
|
| 189 |
} catch (err) {
|
| 190 |
console.error(err);
|
| 191 |
setIngestStatus(
|
|
@@ -198,6 +213,125 @@ export function KnowledgeBasePanel() {
|
|
| 198 |
}
|
| 199 |
}
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
return (
|
| 202 |
<section
|
| 203 |
id="knowledge-base"
|
|
@@ -219,16 +353,6 @@ export function KnowledgeBasePanel() {
|
|
| 219 |
>
|
| 220 |
View All Documents →
|
| 221 |
</Link>
|
| 222 |
-
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 223 |
-
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 224 |
-
Tenant ID
|
| 225 |
-
</label>
|
| 226 |
-
<input
|
| 227 |
-
value={tenantId}
|
| 228 |
-
onChange={(e) => setTenantId(e.target.value)}
|
| 229 |
-
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 230 |
-
/>
|
| 231 |
-
</div>
|
| 232 |
</div>
|
| 233 |
|
| 234 |
{/* Search Section */}
|
|
@@ -387,6 +511,84 @@ export function KnowledgeBasePanel() {
|
|
| 387 |
)}
|
| 388 |
</div>
|
| 389 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
</div>
|
| 391 |
</section>
|
| 392 |
);
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useRef, useEffect } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 6 |
|
| 7 |
type SearchResult = {
|
| 8 |
text: string;
|
|
|
|
| 10 |
relevance?: number;
|
| 11 |
};
|
| 12 |
|
| 13 |
+
type Document = {
|
| 14 |
+
id: number;
|
| 15 |
+
text: string;
|
| 16 |
+
created_at?: string;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
type SourceType = "raw_text" | "url" | "pdf" | "docx" | "txt" | "markdown";
|
| 20 |
|
| 21 |
const API_BASE =
|
| 22 |
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 23 |
|
| 24 |
export function KnowledgeBasePanel() {
|
| 25 |
+
const { tenantId, isLoading: tenantLoading } = useTenant();
|
| 26 |
const [searchQuery, setSearchQuery] = useState("");
|
| 27 |
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
| 28 |
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
| 33 |
const [isIngesting, setIsIngesting] = useState(false);
|
| 34 |
const [ingestStatus, setIngestStatus] = useState<string | null>(null);
|
| 35 |
const [searchError, setSearchError] = useState<string | null>(null);
|
| 36 |
+
const [documents, setDocuments] = useState<Document[]>([]);
|
| 37 |
+
const [isLoadingDocs, setIsLoadingDocs] = useState(false);
|
| 38 |
+
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
| 39 |
+
const [isDeletingAll, setIsDeletingAll] = useState(false);
|
| 40 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 41 |
|
| 42 |
async function handleSearch() {
|
|
|
|
| 134 |
if (fileInputRef.current) {
|
| 135 |
fileInputRef.current.value = "";
|
| 136 |
}
|
| 137 |
+
// Reload documents after successful ingestion
|
| 138 |
+
loadDocuments();
|
| 139 |
} catch (err) {
|
| 140 |
console.error(err);
|
| 141 |
setIngestStatus(
|
|
|
|
| 199 |
if (fileInputRef.current) {
|
| 200 |
fileInputRef.current.value = "";
|
| 201 |
}
|
| 202 |
+
// Reload documents after successful ingestion
|
| 203 |
+
loadDocuments();
|
| 204 |
} catch (err) {
|
| 205 |
console.error(err);
|
| 206 |
setIngestStatus(
|
|
|
|
| 213 |
}
|
| 214 |
}
|
| 215 |
|
| 216 |
+
async function loadDocuments() {
|
| 217 |
+
// Guard against empty tenant ID
|
| 218 |
+
if (!tenantId || !tenantId.trim() || isLoadingDocs) return;
|
| 219 |
+
setIsLoadingDocs(true);
|
| 220 |
+
|
| 221 |
+
try {
|
| 222 |
+
const response = await fetch(`${API_BASE}/rag/list?limit=10&offset=0`, {
|
| 223 |
+
method: "GET",
|
| 224 |
+
headers: {
|
| 225 |
+
"x-tenant-id": tenantId,
|
| 226 |
+
},
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
if (!response.ok) {
|
| 230 |
+
const errorData = await response.json().catch(() => ({}));
|
| 231 |
+
const errorMsg = errorData.detail || errorData.message || `Failed to load documents (${response.status})`;
|
| 232 |
+
|
| 233 |
+
if (response.status === 400) {
|
| 234 |
+
// Missing tenant ID - silently fail, user will see empty list
|
| 235 |
+
console.warn("Cannot load documents: Missing tenant ID");
|
| 236 |
+
setDocuments([]);
|
| 237 |
+
return;
|
| 238 |
+
} else if (response.status === 503) {
|
| 239 |
+
console.error("Cannot connect to RAG MCP server");
|
| 240 |
+
setDocuments([]);
|
| 241 |
+
return;
|
| 242 |
+
} else {
|
| 243 |
+
throw new Error(errorMsg);
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const data = await response.json();
|
| 248 |
+
setDocuments(data.documents || []);
|
| 249 |
+
} catch (err) {
|
| 250 |
+
console.error(err);
|
| 251 |
+
setDocuments([]);
|
| 252 |
+
// Don't show error in status for document loading - it's not critical
|
| 253 |
+
} finally {
|
| 254 |
+
setIsLoadingDocs(false);
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
async function handleDeleteDocument(documentId: number) {
|
| 259 |
+
if (!tenantId.trim() || isDeleting !== null) return;
|
| 260 |
+
setIsDeleting(documentId);
|
| 261 |
+
|
| 262 |
+
try {
|
| 263 |
+
const response = await fetch(`${API_BASE}/rag/delete/${documentId}`, {
|
| 264 |
+
method: "DELETE",
|
| 265 |
+
headers: {
|
| 266 |
+
"x-tenant-id": tenantId,
|
| 267 |
+
},
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
if (!response.ok) {
|
| 271 |
+
const errorData = await response.json().catch(() => ({}));
|
| 272 |
+
const errorMsg = errorData.detail || `Failed to delete document: ${response.status}`;
|
| 273 |
+
throw new Error(errorMsg);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Remove from local state
|
| 277 |
+
setDocuments(docs => docs.filter(doc => doc.id !== documentId));
|
| 278 |
+
setIngestStatus("✅ Document deleted successfully");
|
| 279 |
+
} catch (err) {
|
| 280 |
+
console.error(err);
|
| 281 |
+
setIngestStatus(
|
| 282 |
+
err instanceof Error
|
| 283 |
+
? `❌ Error: ${err.message}`
|
| 284 |
+
: "Failed to delete document",
|
| 285 |
+
);
|
| 286 |
+
} finally {
|
| 287 |
+
setIsDeleting(null);
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
async function handleDeleteAll() {
|
| 292 |
+
if (!tenantId.trim() || isDeletingAll) return;
|
| 293 |
+
|
| 294 |
+
if (!confirm("Are you sure you want to delete ALL documents for this tenant? This action cannot be undone.")) {
|
| 295 |
+
return;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
setIsDeletingAll(true);
|
| 299 |
+
|
| 300 |
+
try {
|
| 301 |
+
const response = await fetch(`${API_BASE}/rag/delete-all`, {
|
| 302 |
+
method: "DELETE",
|
| 303 |
+
headers: {
|
| 304 |
+
"x-tenant-id": tenantId,
|
| 305 |
+
},
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
if (!response.ok) {
|
| 309 |
+
throw new Error(`Failed to delete all documents: ${response.status}`);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const data = await response.json();
|
| 313 |
+
setDocuments([]);
|
| 314 |
+
setIngestStatus(`✅ Deleted ${data.deleted_count || 0} document(s)`);
|
| 315 |
+
} catch (err) {
|
| 316 |
+
console.error(err);
|
| 317 |
+
setIngestStatus(
|
| 318 |
+
err instanceof Error
|
| 319 |
+
? `❌ Error: ${err.message}`
|
| 320 |
+
: "Failed to delete all documents",
|
| 321 |
+
);
|
| 322 |
+
} finally {
|
| 323 |
+
setIsDeletingAll(false);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// Load documents on mount and when tenant changes
|
| 328 |
+
useEffect(() => {
|
| 329 |
+
// Wait for tenant context to finish loading, then load documents if tenant ID is available
|
| 330 |
+
if (!tenantLoading && tenantId && tenantId.trim()) {
|
| 331 |
+
loadDocuments();
|
| 332 |
+
}
|
| 333 |
+
}, [tenantId, tenantLoading]);
|
| 334 |
+
|
| 335 |
return (
|
| 336 |
<section
|
| 337 |
id="knowledge-base"
|
|
|
|
| 353 |
>
|
| 354 |
View All Documents →
|
| 355 |
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
</div>
|
| 357 |
|
| 358 |
{/* Search Section */}
|
|
|
|
| 511 |
)}
|
| 512 |
</div>
|
| 513 |
</div>
|
| 514 |
+
|
| 515 |
+
{/* Manage Documents Section */}
|
| 516 |
+
<div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
| 517 |
+
<div className="flex items-center justify-between mb-4">
|
| 518 |
+
<div>
|
| 519 |
+
<p className="text-sm uppercase tracking-[0.5em] text-slate-400">
|
| 520 |
+
Manage Documents
|
| 521 |
+
</p>
|
| 522 |
+
<p className="mt-2 text-sm text-slate-300">
|
| 523 |
+
View and delete your ingested documents
|
| 524 |
+
</p>
|
| 525 |
+
</div>
|
| 526 |
+
<div className="flex items-center gap-3">
|
| 527 |
+
<button
|
| 528 |
+
onClick={loadDocuments}
|
| 529 |
+
disabled={isLoadingDocs}
|
| 530 |
+
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/10 disabled:opacity-60"
|
| 531 |
+
>
|
| 532 |
+
{isLoadingDocs ? "Loading…" : "Refresh"}
|
| 533 |
+
</button>
|
| 534 |
+
{documents.length > 0 && (
|
| 535 |
+
<button
|
| 536 |
+
onClick={handleDeleteAll}
|
| 537 |
+
disabled={isDeletingAll}
|
| 538 |
+
className="rounded-full border border-red-500/50 bg-red-500/10 px-4 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
|
| 539 |
+
>
|
| 540 |
+
{isDeletingAll ? "Deleting…" : "Delete All"}
|
| 541 |
+
</button>
|
| 542 |
+
)}
|
| 543 |
+
</div>
|
| 544 |
+
</div>
|
| 545 |
+
|
| 546 |
+
{documents.length === 0 && !isLoadingDocs && (
|
| 547 |
+
<p className="text-sm text-slate-400 text-center py-4">
|
| 548 |
+
No documents found. Ingest some content to get started.
|
| 549 |
+
</p>
|
| 550 |
+
)}
|
| 551 |
+
|
| 552 |
+
{isLoadingDocs && (
|
| 553 |
+
<p className="text-sm text-slate-400 text-center py-4">
|
| 554 |
+
Loading documents…
|
| 555 |
+
</p>
|
| 556 |
+
)}
|
| 557 |
+
|
| 558 |
+
{documents.length > 0 && (
|
| 559 |
+
<div className="space-y-2 max-h-96 overflow-y-auto">
|
| 560 |
+
{documents.map((doc) => (
|
| 561 |
+
<div
|
| 562 |
+
key={doc.id}
|
| 563 |
+
className="flex items-start justify-between gap-3 rounded-xl border border-white/10 bg-white/5 p-3"
|
| 564 |
+
>
|
| 565 |
+
<div className="flex-1 min-w-0">
|
| 566 |
+
<p className="text-xs text-slate-400 mb-1">
|
| 567 |
+
ID: {doc.id}
|
| 568 |
+
{doc.created_at && (
|
| 569 |
+
<span className="ml-2">
|
| 570 |
+
• {new Date(doc.created_at).toLocaleDateString()}
|
| 571 |
+
</span>
|
| 572 |
+
)}
|
| 573 |
+
</p>
|
| 574 |
+
<p className="text-sm text-slate-200 line-clamp-2">
|
| 575 |
+
{doc.text.length > 150
|
| 576 |
+
? `${doc.text.substring(0, 150)}...`
|
| 577 |
+
: doc.text}
|
| 578 |
+
</p>
|
| 579 |
+
</div>
|
| 580 |
+
<button
|
| 581 |
+
onClick={() => handleDeleteDocument(doc.id)}
|
| 582 |
+
disabled={isDeleting === doc.id}
|
| 583 |
+
className="flex-shrink-0 rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
|
| 584 |
+
>
|
| 585 |
+
{isDeleting === doc.id ? "Deleting…" : "Delete"}
|
| 586 |
+
</button>
|
| 587 |
+
</div>
|
| 588 |
+
))}
|
| 589 |
+
</div>
|
| 590 |
+
)}
|
| 591 |
+
</div>
|
| 592 |
</div>
|
| 593 |
</section>
|
| 594 |
);
|
frontend/components/tenant-selector.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useTenant } from "@/contexts/TenantContext";
|
| 4 |
+
|
| 5 |
+
export function TenantSelector() {
|
| 6 |
+
const { tenantId, setTenantId } = useTenant();
|
| 7 |
+
|
| 8 |
+
return (
|
| 9 |
+
<div className="flex items-center gap-3">
|
| 10 |
+
<label className="text-sm font-semibold text-slate-200">Tenant ID:</label>
|
| 11 |
+
<input
|
| 12 |
+
value={tenantId}
|
| 13 |
+
onChange={(e) => setTenantId(e.target.value)}
|
| 14 |
+
placeholder="Enter tenant ID"
|
| 15 |
+
className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-2 text-sm text-white outline-none focus:border-cyan-400 min-w-[150px]"
|
| 16 |
+
/>
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
frontend/contexts/TenantContext.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
| 4 |
+
import { DEFAULT_TENANT_ID, TENANT_STORAGE_KEY } from "@/lib/constants";
|
| 5 |
+
|
| 6 |
+
type TenantContextType = {
|
| 7 |
+
tenantId: string;
|
| 8 |
+
setTenantId: (id: string) => void;
|
| 9 |
+
isLoading: boolean;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const TenantContext = createContext<TenantContextType | undefined>(undefined);
|
| 13 |
+
|
| 14 |
+
export function TenantProvider({ children }: { children: ReactNode }) {
|
| 15 |
+
const [tenantId, setTenantIdState] = useState("");
|
| 16 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 17 |
+
|
| 18 |
+
// Load from localStorage on mount
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const saved = localStorage.getItem(TENANT_STORAGE_KEY);
|
| 21 |
+
setTenantIdState(saved || DEFAULT_TENANT_ID);
|
| 22 |
+
setIsLoading(false);
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const setTenantId = (id: string) => {
|
| 26 |
+
const trimmed = id.trim();
|
| 27 |
+
setTenantIdState(trimmed);
|
| 28 |
+
if (trimmed) {
|
| 29 |
+
localStorage.setItem(TENANT_STORAGE_KEY, trimmed);
|
| 30 |
+
} else {
|
| 31 |
+
localStorage.removeItem(TENANT_STORAGE_KEY);
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<TenantContext.Provider value={{ tenantId, setTenantId, isLoading }}>
|
| 37 |
+
{children}
|
| 38 |
+
</TenantContext.Provider>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function useTenant() {
|
| 43 |
+
const context = useContext(TenantContext);
|
| 44 |
+
if (!context) {
|
| 45 |
+
throw new Error("useTenant must be used within TenantProvider");
|
| 46 |
+
}
|
| 47 |
+
return context;
|
| 48 |
+
}
|
| 49 |
+
|
frontend/lib/constants.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEFAULT_TENANT_ID = "tenant123";
|
| 2 |
+
export const TENANT_STORAGE_KEY = "integrachat_tenant_id";
|
| 3 |
+
|