ndash / app.py
kylsprt's picture
Update app.py
ddefba7 verified
Raw
History Blame Contribute Delete
42.4 kB
import os
import time
import json
import hashlib
import requests
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
import extra_streamlit_components as stx
from datetime import datetime, timedelta
RAG_API_URL = os.getenv("RAG_API_URL", "").rstrip("/")
RAG_API_KEY = os.getenv("RAG_API_KEY", "")
APP_USERNAME = os.getenv("USERNAME", "admin")
APP_PASSWORD = os.getenv("PASSWORD", "")
SESSION_DURATION_DAYS = 30
COOKIE_NAME = "nexus_auth_token"
st.set_page_config(
page_title="Nexus RAG",
page_icon="⬡",
layout="wide",
initial_sidebar_state="expanded",
)
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { font-family: 'Inter', sans-serif !important; }
#MainMenu, footer, header { visibility: hidden; }
html, body, [data-testid="stAppViewContainer"] { background: #0a0a0f; color: #e2e8f0; }
[data-testid="stSidebar"] { background: #0d0d18 !important; border-right: 1px solid #1e1e2e; }
[data-testid="stMain"] { background: #0a0a0f; }
.login-title { font-size: 1.8rem; font-weight: 800; color: #e94560; text-align: center; margin-bottom: 0.3rem; letter-spacing: 3px; }
.login-subtitle { font-size: 0.8rem; color: #4a4a6a; text-align: center; margin-bottom: 2rem; letter-spacing: 1px; text-transform: uppercase; }
.login-error { background: rgba(233,69,96,0.1); border: 1px solid rgba(233,69,96,0.3); color: #e94560; padding: 0.8rem 1rem; border-radius: 8px; font-size: 0.85rem; margin-bottom: 1rem; text-align: center; }
.nexus-brand { display: flex; align-items: center; gap: 0.6rem; padding: 1.2rem 0.5rem; border-bottom: 1px solid #1e1e2e; margin-bottom: 1.5rem; }
.nexus-brand-title { font-size: 1rem; font-weight: 800; color: #e94560; letter-spacing: 2px; }
.nexus-brand-sub { font-size: 0.7rem; color: #4a4a6a; letter-spacing: 1px; }
.sidebar-status-box { background: #0a0a0f; border: 1px solid #1e1e2e; border-radius: 8px; padding: 0.8rem; margin-bottom: 1rem; font-size: 0.78rem; color: #6b7280; }
.sidebar-status-row { display: flex; justify-content: space-between; align-items: center; padding: 0.2rem 0; }
.sidebar-status-val { color: #e2e8f0; font-weight: 500; }
.page-title { font-size: 1.6rem; font-weight: 800; color: #e2e8f0; margin-bottom: 0.3rem; }
.page-subtitle { font-size: 0.85rem; color: #4a4a6a; margin-bottom: 1.5rem; }
.card { background: #0d0d18; border: 1px solid #1e1e2e; border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem; }
.card:hover { border-color: #2e2e4e; }
.metric-val { font-size: 2rem; font-weight: 800; color: #e94560; line-height: 1; margin-bottom: 0.3rem; }
.metric-lbl { font-size: 0.72rem; color: #4a4a6a; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
.metric-sub { font-size: 0.78rem; color: #6b7280; margin-top: 0.2rem; }
.table-header { display: flex; padding: 0.5rem 0.8rem; border-bottom: 1px solid #1e1e2e; font-size: 0.72rem; font-weight: 600; color: #4a4a6a; text-transform: uppercase; letter-spacing: 1px; }
.table-row { display: flex; padding: 0.7rem 0.8rem; border-bottom: 1px solid #0f0f1a; font-size: 0.85rem; color: #a0aec0; align-items: center; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.72rem; font-weight: 600; }
.badge-red { background: rgba(233,69,96,0.15); color: #e94560; border: 1px solid rgba(233,69,96,0.2); }
.badge-green { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.2); }
.badge-blue { background: rgba(59,130,246,0.15); color: #3b82f6; border: 1px solid rgba(59,130,246,0.2); }
.badge-gray { background: rgba(107,114,128,0.15); color: #9ca3af; border: 1px solid rgba(107,114,128,0.2); }
.result-block { background: #0a0a0f; border-left: 3px solid #e94560; border-radius: 0 8px 8px 0; padding: 0.8rem 1rem; margin-bottom: 0.6rem; }
.score-high { color: #10b981; font-weight: 700; }
.score-med { color: #f59e0b; font-weight: 700; }
.score-low { color: #e94560; font-weight: 700; }
.alert-success { background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.2); color: #10b981; padding: 0.8rem 1rem; border-radius: 8px; font-size: 0.85rem; margin-bottom: 0.5rem; }
.alert-error { background: rgba(233,69,96,0.08); border: 1px solid rgba(233,69,96,0.2); color: #e94560; padding: 0.8rem 1rem; border-radius: 8px; font-size: 0.85rem; margin-bottom: 0.5rem; }
.alert-info { background: rgba(59,130,246,0.08); border: 1px solid rgba(59,130,246,0.2); color: #3b82f6; padding: 0.8rem 1rem; border-radius: 8px; font-size: 0.85rem; margin-bottom: 0.5rem; }
div[data-testid="stTextInput"] input { background: #0a0a0f !important; border: 1px solid #1e1e2e !important; border-radius: 8px !important; color: #e2e8f0 !important; }
div[data-testid="stTextInput"] input:focus { border-color: #e94560 !important; box-shadow: 0 0 0 2px rgba(233,69,96,0.15) !important; }
div[data-testid="stSelectbox"] > div > div { background: #0a0a0f !important; border: 1px solid #1e1e2e !important; border-radius: 8px !important; color: #e2e8f0 !important; }
div[data-testid="stTextArea"] textarea { background: #0a0a0f !important; border: 1px solid #1e1e2e !important; border-radius: 8px !important; color: #e2e8f0 !important; }
div[data-testid="stTextArea"] textarea:focus { border-color: #e94560 !important; box-shadow: 0 0 0 2px rgba(233,69,96,0.15) !important; }
div[data-testid="stTextInput"] label, div[data-testid="stTextArea"] label, div[data-testid="stSelectbox"] label, div[data-testid="stSlider"] label, div[data-testid="stCheckbox"] label, div[data-testid="stFileUploader"] label, div[data-testid="stNumberInput"] label { color: #6b7280 !important; font-size: 0.8rem !important; font-weight: 500 !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; }
.stButton > button { background: #e94560 !important; color: white !important; border: none !important; border-radius: 8px !important; font-weight: 600 !important; font-size: 0.875rem !important; padding: 0.55rem 1.5rem !important; width: 100% !important; transition: opacity 0.2s !important; }
.stButton > button:hover { opacity: 0.85 !important; color: white !important; }
div[data-testid="stTabs"] button { color: #4a4a6a !important; font-weight: 500 !important; font-size: 0.85rem !important; }
div[data-testid="stTabs"] button[aria-selected="true"] { color: #e94560 !important; border-bottom-color: #e94560 !important; }
hr { border-color: #1e1e2e !important; margin: 1rem 0 !important; }
div[data-testid="stExpander"] { background: #0d0d18 !important; border: 1px solid #1e1e2e !important; border-radius: 8px !important; }
[data-testid="stFileUploader"] { background: #0a0a0f !important; border: 2px dashed #1e1e2e !important; border-radius: 8px !important; }
.logout-btn > button { background: transparent !important; border: 1px solid #1e1e2e !important; color: #6b7280 !important; font-size: 0.78rem !important; }
.logout-btn > button:hover { border-color: #e94560 !important; color: #e94560 !important; opacity: 1 !important; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: #0a0a0f; }
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: #e94560; }
</style>
""", unsafe_allow_html=True)
LUCIDE_ICONS = {
"lock": """<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#e94560" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>""",
}
PLOTLY_LAYOUT = dict(
paper_bgcolor="#0d0d18",
plot_bgcolor="#0a0a0f",
font=dict(color="#6b7280", family="Inter", size=12),
margin=dict(t=30, b=30, l=10, r=10),
xaxis=dict(gridcolor="#1e1e2e", zerolinecolor="#1e1e2e"),
yaxis=dict(gridcolor="#1e1e2e", zerolinecolor="#1e1e2e"),
legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color="#6b7280")),
)
COLOR_PRIMARY = "#e94560"
COLOR_SECONDARY = "#3b82f6"
COLOR_SUCCESS = "#10b981"
COLOR_WARNING = "#f59e0b"
def get_cookie_manager():
if "cookie_manager" not in st.session_state:
st.session_state.cookie_manager = stx.CookieManager(key="nexus_cookie_mgr")
return st.session_state.cookie_manager
def generate_token(username: str) -> str:
secret = APP_PASSWORD + username + "nexus_salt_2024"
return hashlib.sha256(secret.encode()).hexdigest()
def verify_token(token: str) -> bool:
return token == generate_token(APP_USERNAME)
def check_auth() -> bool:
try:
cm = get_cookie_manager()
token = cm.get(COOKIE_NAME)
if token and verify_token(str(token)):
return True
except Exception:
pass
return False
def do_login(username: str, password: str) -> bool:
if username.strip() == APP_USERNAME and password == APP_PASSWORD:
token = generate_token(username)
expiry = datetime.now() + timedelta(days=SESSION_DURATION_DAYS)
try:
cm = get_cookie_manager()
cm.set(COOKIE_NAME, token, expires_at=expiry, key="cookie_set_login")
except Exception:
pass
return True
return False
def do_logout():
try:
cm = get_cookie_manager()
cm.delete(COOKIE_NAME, key="cookie_del_logout")
except Exception:
pass
st.session_state.authenticated = False
st.session_state.current_page = "Overview"
st.rerun()
def api_get(endpoint: str) -> tuple[bool, dict]:
try:
r = requests.get(
f"{RAG_API_URL}{endpoint}",
headers={"Authorization": f"Bearer {RAG_API_KEY}"},
timeout=15,
)
return r.status_code == 200, r.json()
except Exception as e:
return False, {"error": str(e)}
def api_post(endpoint: str, json_data: dict = None, files=None, data=None) -> tuple[bool, dict]:
try:
headers = {"Authorization": f"Bearer {RAG_API_KEY}"}
if json_data is not None:
headers["Content-Type"] = "application/json"
r = requests.post(f"{RAG_API_URL}{endpoint}", headers=headers, json=json_data, timeout=120)
else:
r = requests.post(f"{RAG_API_URL}{endpoint}", headers=headers, files=files, data=data, timeout=120)
return r.status_code == 200, r.json()
except Exception as e:
return False, {"error": str(e)}
def api_delete(endpoint: str) -> tuple[bool, dict]:
try:
r = requests.delete(
f"{RAG_API_URL}{endpoint}",
headers={"Authorization": f"Bearer {RAG_API_KEY}"},
timeout=15,
)
return r.status_code == 200, r.json()
except Exception as e:
return False, {"error": str(e)}
def get_health() -> dict:
try:
r = requests.get(f"{RAG_API_URL}/health", timeout=10)
return r.json() if r.status_code == 200 else {}
except Exception:
return {}
def render_login():
col1, col2, col3 = st.columns([1, 1.2, 1])
with col2:
st.markdown(f"""
<div style="text-align:center;padding:3rem 0 1.5rem 0;">{LUCIDE_ICONS['lock']}</div>
<div class="login-title">NEXUS RAG</div>
<div class="login-subtitle">Restricted Access — Authenticate to Continue</div>
""", unsafe_allow_html=True)
if st.session_state.get("login_error"):
st.markdown(f'<div class="login-error">{st.session_state.login_error}</div>', unsafe_allow_html=True)
with st.form("login_form", clear_on_submit=False):
username = st.text_input("Username", placeholder="Enter username")
password = st.text_input("Password", type="password", placeholder="Enter password")
submitted = st.form_submit_button("Sign In", use_container_width=True)
if submitted:
if not username or not password:
st.session_state.login_error = "Username and password are required."
st.rerun()
else:
success = do_login(username, password)
if success:
st.session_state.login_error = ""
st.session_state.authenticated = True
st.rerun()
else:
st.session_state.login_error = "Invalid credentials. Access denied."
st.rerun()
st.markdown("""
<div style="text-align:center;margin-top:2rem;font-size:0.72rem;color:#2e2e4e;letter-spacing:1px;">
NEXUS RAG ENGINE &mdash; SECURED
</div>
""", unsafe_allow_html=True)
def render_sidebar(health: dict) -> str:
with st.sidebar:
status = health.get("status", "offline")
status_color = "#10b981" if status == "healthy" else "#f59e0b" if status == "degraded" else "#e94560"
status_label = status.upper() if status else "OFFLINE"
st.markdown(f"""
<div class="nexus-brand">
<div>
<div class="nexus-brand-title">NEXUS RAG</div>
<div class="nexus-brand-sub">Engine Dashboard</div>
</div>
<div style="margin-left:auto;">
<span style="color:{status_color};font-size:0.7rem;font-weight:700;
background:rgba(0,0,0,0.3);padding:0.2rem 0.5rem;border-radius:20px;
border:1px solid {status_color}33;">
{status_label}
</span>
</div>
</div>
""", unsafe_allow_html=True)
if health:
uptime = health.get("uptime_seconds", 0)
hours = int(uptime // 3600)
minutes = int((uptime % 3600) // 60)
qdrant_ok = health.get("qdrant_connection", "error") == "ok"
cache_n = health.get("cache_entries", 0)
st.markdown(f"""
<div class="sidebar-status-box">
<div class="sidebar-status-row">
<span>Uptime</span>
<span class="sidebar-status-val">{hours}h {minutes}m</span>
</div>
<div class="sidebar-status-row">
<span>Qdrant</span>
<span class="sidebar-status-val" style="color:{'#10b981' if qdrant_ok else '#e94560'}">
{'Connected' if qdrant_ok else 'Error'}
</span>
</div>
<div class="sidebar-status-row">
<span>Cache</span>
<span class="sidebar-status-val">{cache_n} entries</span>
</div>
</div>
""", unsafe_allow_html=True)
pages = ["Overview", "Upload", "Test Search", "Documents", "Analytics"]
for label in pages:
is_active = st.session_state.get("current_page", "Overview") == label
if st.button(
label,
key=f"nav_{label}",
use_container_width=True,
type="primary" if is_active else "secondary",
):
st.session_state.current_page = label
st.rerun()
st.markdown("<br>", unsafe_allow_html=True)
st.markdown("<div style='border-top:1px solid #1e1e2e;padding-top:1rem;'></div>", unsafe_allow_html=True)
st.markdown(
f"<div style='font-size:0.72rem;color:#2e2e4e;margin-bottom:0.5rem;'>"
f"Signed in as <span style='color:#4a4a6a;'>{APP_USERNAME}</span></div>",
unsafe_allow_html=True,
)
st.markdown('<div class="logout-btn">', unsafe_allow_html=True)
if st.button("Sign Out", key="logout_btn", use_container_width=True):
do_logout()
st.markdown("</div>", unsafe_allow_html=True)
return st.session_state.get("current_page", "Overview")
def page_overview(stats: dict, health: dict):
st.markdown('<div class="page-title">Overview</div>', unsafe_allow_html=True)
st.markdown('<div class="page-subtitle">System status and key metrics at a glance.</div>', unsafe_allow_html=True)
storage = stats.get("storage", {})
documents = stats.get("documents", {})
queries = stats.get("queries", {})
c1, c2, c3, c4 = st.columns(4)
for col, val, label, sub in [
(c1, documents.get("total_parents", 0), "Parent Chunks", "Indexed parent segments"),
(c2, documents.get("total_children", 0), "Child Chunks", "Searchable vectors"),
(c3, queries.get("today", 0), "Queries Today", "RAG calls this session"),
(c4, queries.get("cache_size", 0), "Cache Entries", "In-memory cached results"),
]:
with col:
st.markdown(f"""
<div class="card" style="text-align:center;">
<div class="metric-val">{val:,}</div>
<div class="metric-lbl">{label}</div>
<div class="metric-sub">{sub}</div>
</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
col_l, col_r = st.columns(2)
with col_l:
used = storage.get("used_mb", 0)
total = storage.get("total_mb", 1024)
pct = storage.get("percentage", 0)
bar_color = COLOR_SUCCESS if pct < 60 else COLOR_WARNING if pct < 85 else COLOR_PRIMARY
fig_gauge = go.Figure(go.Indicator(
mode="gauge+number",
value=pct,
number={"suffix": "%", "font": {"color": COLOR_PRIMARY, "size": 32}},
title={"text": f"Storage {used:.1f} MB / {total} MB", "font": {"color": "#6b7280", "size": 13}},
gauge={
"axis": {"range": [0, 100], "tickcolor": "#1e1e2e", "tickfont": {"color": "#4a4a6a"}},
"bar": {"color": bar_color},
"bgcolor": "#0a0a0f",
"borderwidth": 0,
"steps": [
{"range": [0, 60], "color": "#0d0d18"},
{"range": [60, 85], "color": "#111122"},
{"range": [85, 100], "color": "#150a0a"},
],
"threshold": {"line": {"color": COLOR_PRIMARY, "width": 2}, "thickness": 0.75, "value": 90},
},
))
fig_gauge.update_layout(**PLOTLY_LAYOUT, height=260)
st.plotly_chart(fig_gauge, use_container_width=True)
with col_r:
top_queries = stats.get("top_queries", [])
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.8rem;">Top Queries</div>', unsafe_allow_html=True)
if top_queries:
max_count = top_queries[0].get("count", 1)
for q in top_queries[:6]:
qtext = q.get("query", "")[:45]
count = q.get("count", 0)
pct_bar = int((count / max_count) * 100)
st.markdown(f"""
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;margin-bottom:3px;">
<span style="font-size:0.8rem;color:#a0aec0;">{qtext}</span>
<span style="font-size:0.78rem;color:{COLOR_PRIMARY};font-weight:600;">{count}x</span>
</div>
<div style="background:#1e1e2e;border-radius:2px;height:3px;">
<div style="background:{COLOR_PRIMARY};width:{pct_bar}%;height:3px;border-radius:2px;"></div>
</div>
</div>
""", unsafe_allow_html=True)
else:
st.markdown('<div class="alert-info">No query logs yet.</div>', unsafe_allow_html=True)
rate_limits = stats.get("rate_limits", {})
if rate_limits:
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin:1rem 0 0.8rem 0;">Rate Limit — Current Hour</div>', unsafe_allow_html=True)
rl_cols = st.columns(min(len(rate_limits), 4))
for i, (key, count) in enumerate(rate_limits.items()):
pct_rl = (count / 3000) * 100
rl_color = COLOR_SUCCESS if pct_rl < 70 else COLOR_WARNING if pct_rl < 90 else COLOR_PRIMARY
with rl_cols[i % 4]:
st.markdown(f"""
<div class="card">
<div style="font-size:0.72rem;color:#4a4a6a;margin-bottom:0.3rem;font-family:monospace;">{key}</div>
<div style="font-size:1.4rem;font-weight:700;color:{rl_color};">{count}<span style="font-size:0.75rem;color:#4a4a6a;">/3000</span></div>
<div style="background:#1e1e2e;border-radius:2px;height:3px;margin-top:0.5rem;">
<div style="background:{rl_color};width:{min(pct_rl,100):.1f}%;height:3px;border-radius:2px;"></div>
</div>
</div>
""", unsafe_allow_html=True)
def page_upload():
st.markdown('<div class="page-title">Upload Documents</div>', unsafe_allow_html=True)
st.markdown('<div class="page-subtitle">Add .md or .txt files to the Nexus RAG knowledge base.</div>', unsafe_allow_html=True)
ok, collections_data = api_get("/collections")
existing_collections = collections_data.get("collections", []) if ok else []
with st.form("upload_form", clear_on_submit=True):
uploaded_files = st.file_uploader("Select files", type=["md", "txt"], accept_multiple_files=True, help="Max 50MB per file")
c1, c2 = st.columns(2)
with c1:
col_mode = st.selectbox("Collection mode", ["Use existing", "Create new"])
with c2:
if col_mode == "Use existing" and existing_collections:
collection_name = st.selectbox("Collection", existing_collections)
else:
collection_name = st.text_input("Collection name", placeholder="e.g. devops, tutorials")
metadata_raw = st.text_area("Metadata JSON (optional)", placeholder='{"category": "devops"}', height=70)
submitted = st.form_submit_button("Upload", use_container_width=True)
if submitted:
if not uploaded_files:
st.markdown('<div class="alert-error">Select at least one file.</div>', unsafe_allow_html=True)
return
if not collection_name or not collection_name.strip():
st.markdown('<div class="alert-error">Collection name is required.</div>', unsafe_allow_html=True)
return
meta_dict = {}
if metadata_raw.strip():
try:
meta_dict = json.loads(metadata_raw.strip())
except json.JSONDecodeError:
st.markdown('<div class="alert-error">Invalid JSON in metadata.</div>', unsafe_allow_html=True)
return
progress = st.progress(0, text="Preparing...")
results_box = st.container()
success_n, fail_n = 0, 0
for i, f in enumerate(uploaded_files):
progress.progress(i / len(uploaded_files), text=f"Uploading {f.name}...")
raw = f.read()
size_mb = len(raw) / (1024 * 1024)
if size_mb > 50:
with results_box:
st.markdown(f'<div class="alert-error">{f.name} exceeds 50MB ({size_mb:.1f}MB)</div>', unsafe_allow_html=True)
fail_n += 1
continue
ok, resp = api_post("/upload", files={"file": (f.name, raw, "text/plain")}, data={"collection": collection_name.strip(), "metadata": json.dumps(meta_dict)})
with results_box:
if ok and resp.get("success"):
st.markdown(f'<div class="alert-success">{f.name}{resp.get("parents_created",0)} parents, {resp.get("children_created",0)} children — {size_mb:.2f}MB — {resp.get("processing_time_ms",0):.0f}ms</div>', unsafe_allow_html=True)
success_n += 1
else:
st.markdown(f'<div class="alert-error">{f.name}{resp.get("error") or resp.get("detail","Unknown error")}</div>', unsafe_allow_html=True)
fail_n += 1
progress.progress(1.0, text="Done")
st.markdown("<hr>", unsafe_allow_html=True)
c1, c2, c3 = st.columns(3)
c1.metric("Uploaded", success_n)
c2.metric("Failed", fail_n)
c3.metric("Collection", collection_name)
def page_test_search():
st.markdown('<div class="page-title">Test Search</div>', unsafe_allow_html=True)
st.markdown('<div class="page-subtitle">Run live queries against the Nexus RAG engine.</div>', unsafe_allow_html=True)
ok, collections_data = api_get("/collections")
collections = ["all"] + (collections_data.get("collections", []) if ok else [])
with st.form("search_form"):
query_text = st.text_area("Query", placeholder="Type your query here...", height=90)
c1, c2, c3 = st.columns(3)
with c1:
selected_col = st.selectbox("Collection", collections)
with c2:
top_k = st.slider("Top K", 1, 20, 5)
with c3:
use_rerank = st.checkbox("Reranking", value=True)
submitted = st.form_submit_button("Search", use_container_width=True)
if submitted:
if not query_text.strip():
st.markdown('<div class="alert-error">Enter a query.</div>', unsafe_allow_html=True)
return
with st.spinner("Processing..."):
t0 = time.time()
ok, resp = api_post("/query", json_data={"query": query_text.strip(), "collection": selected_col, "top_k": top_k, "use_reranking": use_rerank})
elapsed = (time.time() - t0) * 1000
if not ok:
st.markdown(f'<div class="alert-error">Query failed: {resp.get("error","Unknown")}</div>', unsafe_allow_html=True)
return
c1, c2, c3, c4 = st.columns(4)
c1.metric("Latency", f"{resp.get('processing_time_ms', elapsed):.0f}ms")
c2.metric("Sources", len(resp.get("sources", [])))
c3.metric("Cached", "Yes" if resp.get("cached") else "No")
c4.metric("Reranked", "Yes" if use_rerank else "No")
st.markdown("<hr>", unsafe_allow_html=True)
tab_ctx, tab_src = st.tabs(["Context Output", "Sources Detail"])
with tab_ctx:
context = resp.get("context", "")
if context:
st.markdown(f'<div style="font-size:0.78rem;color:#4a4a6a;margin-bottom:0.5rem;">Context length: {len(context):,} chars</div>', unsafe_allow_html=True)
st.text_area("", value=context, height=400, label_visibility="collapsed")
else:
st.markdown('<div class="alert-info">No context returned.</div>', unsafe_allow_html=True)
with tab_src:
sources = resp.get("sources", [])
if sources:
for i, src in enumerate(sources, 1):
score = src.get("score", 0)
score_cls = "score-high" if score > 0.7 else "score-med" if score > 0.5 else "score-low"
preview = src.get("text", "")[:300]
dots = "..." if len(src.get("text", "")) > 300 else ""
st.markdown(f"""
<div class="result-block">
<div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
<span style="font-size:0.78rem;color:#4a4a6a;">Result #{i}</span>
<span class="{score_cls}">{score:.4f}</span>
</div>
<div style="font-size:0.875rem;color:#a0aec0;margin-bottom:0.5rem;">{preview}{dots}</div>
<div style="font-size:0.72rem;color:#4a4a6a;">
{src.get('filename','unknown')} &middot; {src.get('collection','unknown')} &middot; {src.get('doc_id','')[:12]}...
</div>
</div>
""", unsafe_allow_html=True)
else:
st.markdown('<div class="alert-info">No sources found.</div>', unsafe_allow_html=True)
def page_documents():
st.markdown('<div class="page-title">Documents</div>', unsafe_allow_html=True)
st.markdown('<div class="page-subtitle">Manage all indexed documents in the knowledge base.</div>', unsafe_allow_html=True)
ok, collections_data = api_get("/collections")
collections = ["all"] + (collections_data.get("collections", []) if ok else [])
c1, c2 = st.columns([3, 1])
with c1:
filter_col = st.selectbox("Filter by collection", collections, key="doc_filter_col")
with c2:
st.markdown("<br>", unsafe_allow_html=True)
if st.button("Refresh", use_container_width=True):
st.rerun()
col_param = "" if filter_col == "all" else f"&collection={filter_col}"
ok, docs_data = api_get(f"/documents?limit=200{col_param}")
if not ok:
st.markdown('<div class="alert-error">Failed to fetch documents.</div>', unsafe_allow_html=True)
return
documents = docs_data.get("documents", [])
total = docs_data.get("total", 0)
st.markdown(f'<div style="font-size:0.78rem;color:#4a4a6a;margin-bottom:1rem;">{total} document(s) found</div>', unsafe_allow_html=True)
if not documents:
st.markdown('<div class="alert-info">No documents indexed yet.</div>', unsafe_allow_html=True)
return
if "del_confirm" not in st.session_state:
st.session_state.del_confirm = {}
st.markdown('<div class="card" style="padding:0;">', unsafe_allow_html=True)
st.markdown("""
<div class="table-header">
<span style="flex:3;">Filename</span>
<span style="flex:2;">Collection</span>
<span style="flex:2;">Doc ID</span>
<span style="flex:1;text-align:center;">Parents</span>
<span style="flex:1;text-align:center;">Action</span>
</div>
""", unsafe_allow_html=True)
for doc in documents:
doc_id = doc.get("doc_id", "")
filename = doc.get("filename", "unknown")
collection = doc.get("collection", "general")
parent_count = doc.get("parent_count", 0)
ext = "MD" if filename.endswith(".md") else "TXT"
badge_color = "badge-blue" if ext == "MD" else "badge-gray"
c1, c2, c3, c4, c5 = st.columns([3, 2, 2, 1, 1])
with c1:
st.markdown(f'<span class="badge {badge_color}">{ext}</span> <span style="font-size:0.85rem;color:#e2e8f0;">{filename}</span>', unsafe_allow_html=True)
with c2:
st.markdown(f'<span style="font-size:0.85rem;color:#6b7280;">{collection}</span>', unsafe_allow_html=True)
with c3:
st.markdown(f'<code style="font-size:0.72rem;color:#4a4a6a;">{doc_id[:16]}...</code>', unsafe_allow_html=True)
with c4:
st.markdown(f'<div style="text-align:center;font-size:0.875rem;color:#e2e8f0;font-weight:600;">{parent_count}</div>', unsafe_allow_html=True)
with c5:
if st.session_state.del_confirm.get(doc_id):
if st.button("Confirm", key=f"confirm_{doc_id}", use_container_width=True):
ok, _ = api_delete(f"/delete/{doc_id}")
if ok:
st.session_state.del_confirm[doc_id] = False
st.rerun()
else:
st.markdown('<div class="alert-error">Delete failed.</div>', unsafe_allow_html=True)
st.session_state.del_confirm[doc_id] = False
else:
if st.button("Delete", key=f"del_{doc_id}", use_container_width=True):
st.session_state.del_confirm[doc_id] = True
st.rerun()
st.markdown("<hr style='margin:0.1rem 0;'>", unsafe_allow_html=True)
st.markdown("</div>", unsafe_allow_html=True)
def page_analytics(stats: dict):
st.markdown('<div class="page-title">Analytics</div>', unsafe_allow_html=True)
st.markdown('<div class="page-subtitle">Performance insights, usage trends, and system health reports.</div>', unsafe_allow_html=True)
queries_data = stats.get("queries", {})
top_queries = stats.get("top_queries", [])
storage = stats.get("storage", {})
documents = stats.get("documents", {})
c1, c2, c3, c4, c5 = st.columns(5)
for col, val, label in [
(c1, queries_data.get("total_logged", 0), "Total Logged"),
(c2, queries_data.get("today", 0), "Today"),
(c3, queries_data.get("cache_size", 0), "Cache Size"),
(c4, f"{storage.get('used_mb', 0):.1f}", "Storage MB"),
(c5, f"{storage.get('percentage', 0):.1f}%", "Usage"),
]:
with col:
st.markdown(f"""
<div class="card" style="text-align:center;padding:0.8rem;">
<div style="font-size:1.5rem;font-weight:800;color:{COLOR_PRIMARY};">{val}</div>
<div style="font-size:0.7rem;color:#4a4a6a;text-transform:uppercase;letter-spacing:1px;">{label}</div>
</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
row1_l, row1_r = st.columns(2)
with row1_l:
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.5rem;">Top Queries — Frequency</div>', unsafe_allow_html=True)
if top_queries:
df_q = pd.DataFrame(top_queries)
df_q.columns = ["Query", "Count"]
df_q["Query"] = df_q["Query"].str[:35]
fig = px.bar(df_q.head(10), x="Count", y="Query", orientation="h", color="Count", color_continuous_scale=["#1e1e2e", COLOR_PRIMARY])
fig.update_layout(**PLOTLY_LAYOUT, height=320, coloraxis_showscale=False)
fig.update_traces(marker_line_width=0)
st.plotly_chart(fig, use_container_width=True)
else:
st.markdown('<div class="alert-info">No query data yet.</div>', unsafe_allow_html=True)
with row1_r:
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.5rem;">Storage Distribution</div>', unsafe_allow_html=True)
child_count = documents.get("total_children", 0)
parent_count = documents.get("total_parents", 0)
used_mb = storage.get("used_mb", 0)
free_mb = max(storage.get("total_mb", 1024) - used_mb, 0)
if child_count + parent_count > 0:
fig_pie = go.Figure(go.Pie(
labels=["Child Vectors", "Parent Texts", "Free"],
values=[child_count * 3, parent_count * 1, max(free_mb, 1)],
hole=0.55,
marker=dict(colors=[COLOR_PRIMARY, COLOR_SECONDARY, "#1e1e2e"]),
textfont=dict(color="#6b7280", size=11),
))
fig_pie.update_layout(**PLOTLY_LAYOUT, height=320)
fig_pie.update_traces(textposition="outside")
st.plotly_chart(fig_pie, use_container_width=True)
else:
st.markdown('<div class="alert-info">No storage data yet.</div>', unsafe_allow_html=True)
row2_l, row2_r = st.columns(2)
with row2_l:
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.5rem;">Chunk Ratio Analysis</div>', unsafe_allow_html=True)
if parent_count > 0:
ratio = child_count / parent_count
fig_bar = go.Figure()
fig_bar.add_trace(go.Bar(
x=["Parent Chunks", "Child Chunks"],
y=[parent_count, child_count],
marker_color=[COLOR_SECONDARY, COLOR_PRIMARY],
marker_line_width=0,
text=[f"{parent_count:,}", f"{child_count:,}"],
textposition="outside",
textfont=dict(color="#6b7280", size=11),
))
fig_bar.update_layout(**PLOTLY_LAYOUT, height=280, showlegend=False)
st.plotly_chart(fig_bar, use_container_width=True)
st.markdown(f"""
<div class="card" style="text-align:center;padding:0.7rem;">
<span style="font-size:0.78rem;color:#4a4a6a;">Avg children per parent: </span>
<span style="font-size:1rem;font-weight:700;color:{COLOR_PRIMARY};">{ratio:.1f}x</span>
</div>
""", unsafe_allow_html=True)
else:
st.markdown('<div class="alert-info">No chunk data yet.</div>', unsafe_allow_html=True)
with row2_r:
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.5rem;">Cache vs Live Queries</div>', unsafe_allow_html=True)
total_logged = queries_data.get("total_logged", 0)
cache_size = queries_data.get("cache_size", 0)
live_queries = max(total_logged - cache_size, 0)
if total_logged > 0:
fig_donut = go.Figure(go.Pie(
labels=["Live (Computed)", "Cached (Fast)"],
values=[live_queries, cache_size],
hole=0.6,
marker=dict(colors=[COLOR_PRIMARY, COLOR_SUCCESS]),
textfont=dict(color="#6b7280", size=11),
))
fig_donut.update_layout(**PLOTLY_LAYOUT, height=280)
fig_donut.update_traces(textposition="outside")
st.plotly_chart(fig_donut, use_container_width=True)
hit_rate = (cache_size / total_logged * 100) if total_logged > 0 else 0
st.markdown(f"""
<div class="card" style="text-align:center;padding:0.7rem;">
<span style="font-size:0.78rem;color:#4a4a6a;">Cache hit rate: </span>
<span style="font-size:1rem;font-weight:700;color:{COLOR_SUCCESS};">{hit_rate:.1f}%</span>
</div>
""", unsafe_allow_html=True)
else:
st.markdown('<div class="alert-info">No query data yet.</div>', unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.5rem;">Storage Utilization</div>', unsafe_allow_html=True)
pct = storage.get("percentage", 0)
fig_bullet = go.Figure(go.Indicator(
mode="number+gauge+delta",
value=pct,
delta={"reference": 70, "increasing": {"color": COLOR_PRIMARY}, "decreasing": {"color": COLOR_SUCCESS}},
number={"suffix": "%", "font": {"color": COLOR_PRIMARY, "size": 28}},
gauge={
"shape": "bullet",
"axis": {"range": [0, 100], "tickfont": {"color": "#4a4a6a"}},
"threshold": {"line": {"color": COLOR_PRIMARY, "width": 2}, "thickness": 0.75, "value": 90},
"bgcolor": "#0a0a0f",
"steps": [
{"range": [0, 60], "color": "#0d1117"},
{"range": [60, 80], "color": "#111827"},
{"range": [80, 100], "color": "#1a0a0a"},
],
"bar": {"color": COLOR_PRIMARY},
},
title={"text": f"Used {storage.get('used_mb',0):.1f} MB of {storage.get('total_mb',1024)} MB", "font": {"color": "#4a4a6a", "size": 12}},
))
fig_bullet.update_layout(**PLOTLY_LAYOUT, height=160)
st.plotly_chart(fig_bullet, use_container_width=True)
st.markdown("<br>", unsafe_allow_html=True)
st.markdown('<div style="font-size:0.8rem;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:0.8rem;">Engine Configuration</div>', unsafe_allow_html=True)
config_items = [
("Embedding Model", "LazarusNLP/all-indobert-base-v2"),
("Reranking Model", "cross-encoder/ms-marco-MiniLM-L-6-v2"),
("Vector Dimension", "768"),
("Parent Chunk", "1500 chars"),
("Child Chunk", "500 chars"),
("Child Overlap", "50 chars"),
("Rerank Candidates", "20"),
("Cache TTL", "3600s"),
("Rate Limit", "3000 req/hr"),
("Max File Size", "50 MB"),
]
cfg_c1, cfg_c2 = st.columns(2)
for i, (key, val) in enumerate(config_items):
col = cfg_c1 if i % 2 == 0 else cfg_c2
with col:
st.markdown(f"""
<div style="display:flex;justify-content:space-between;align-items:center;
padding:0.45rem 0.8rem;border-radius:6px;margin-bottom:0.3rem;
background:{'#0d0d18' if i//2%2==0 else '#0a0a0f'};">
<span style="font-size:0.82rem;color:#6b7280;">{key}</span>
<code style="font-size:0.82rem;color:{COLOR_PRIMARY};background:rgba(233,69,96,0.08);
padding:0.1rem 0.4rem;border-radius:4px;">{val}</code>
</div>
""", unsafe_allow_html=True)
def main():
if not RAG_API_URL or not RAG_API_KEY:
st.markdown('<div class="alert-error">RAG_API_URL and RAG_API_KEY secrets not configured.</div>', unsafe_allow_html=True)
return
if not APP_PASSWORD:
st.markdown('<div class="alert-error">PASSWORD secret not configured.</div>', unsafe_allow_html=True)
return
get_cookie_manager()
if "authenticated" not in st.session_state:
st.session_state.authenticated = False
if "login_error" not in st.session_state:
st.session_state.login_error = ""
if "current_page" not in st.session_state:
st.session_state.current_page = "Overview"
if "del_confirm" not in st.session_state:
st.session_state.del_confirm = {}
if not st.session_state.authenticated:
if check_auth():
st.session_state.authenticated = True
else:
render_login()
return
health = get_health()
ok_stats, stats = api_get("/stats")
stats = stats if ok_stats else {}
page = render_sidebar(health)
if page == "Overview":
page_overview(stats, health)
elif page == "Upload":
page_upload()
elif page == "Test Search":
page_test_search()
elif page == "Documents":
page_documents()
elif page == "Analytics":
page_analytics(stats)
if __name__ == "__main__":
main()