| import streamlit as st |
| import pypdf |
| import numpy as np |
| import os |
| import re |
| import ast |
| import json |
| from datetime import datetime |
|
|
| |
| st.set_page_config( |
| page_title="Nexus AI", |
| page_icon="β‘", |
| layout="wide", |
| initial_sidebar_state="expanded", |
| ) |
|
|
| |
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap'); |
| |
| :root { |
| --bg: #07080f; |
| --surface: #0d0f1a; |
| --surface2: #131625; |
| --surface3: #1a1d30; |
| --border: #1e2235; |
| --border2: #252840; |
| --text: #dde1f0; |
| --muted: #5a6080; |
| --faint: #1e2235; |
| --accent: #00c9a7; |
| --accent2: #00e8c3; |
| --accent-dim:#003d32; |
| --gold: #f5a623; |
| --red: #ff6b6b; |
| --blue: #4f9eff; |
| } |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| html, body, [class*="css"] { |
| font-family: 'Plus Jakarta Sans', sans-serif; |
| background: var(--bg) !important; |
| color: var(--text); |
| } |
| #MainMenu, footer, header { visibility: hidden; } |
| .stApp { background: var(--bg) !important; } |
| |
| /* ββ Sidebar ββ */ |
| [data-testid="stSidebar"] { |
| background: var(--surface) !important; |
| border-right: 1px solid var(--border) !important; |
| width: 290px !important; |
| } |
| [data-testid="stSidebar"] > div:first-child { padding: 0 !important; } |
| [data-testid="stSidebar"] * { color: var(--text) !important; } |
| [data-testid="stSidebar"] .stFileUploader label { display: none !important; } |
| [data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] { |
| background: var(--surface2) !important; |
| border: 1.5px dashed var(--border2) !important; |
| border-radius: 12px !important; |
| padding: 14px !important; |
| transition: all 0.2s; |
| } |
| [data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"]:hover { |
| border-color: var(--accent) !important; |
| background: rgba(0,201,167,0.04) !important; |
| } |
| [data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] p { |
| color: var(--muted) !important; font-size: 11px !important; |
| } |
| [data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] button { |
| background: var(--surface3) !important; |
| border: 1px solid var(--border2) !important; |
| color: var(--accent) !important; |
| border-radius: 8px !important; |
| font-size: 11px !important; |
| } |
| [data-testid="stSidebar"] .stRadio label { font-size: 12px !important; } |
| [data-testid="stSidebar"] .stRadio [data-testid="stWidgetLabel"] { display: none !important; } |
| [data-testid="stSidebar"] .stTextInput input { |
| background: var(--surface2) !important; |
| border: 1px solid var(--border2) !important; |
| border-radius: 8px !important; |
| color: var(--text) !important; |
| font-size: 12px !important; |
| font-family: 'JetBrains Mono', monospace !important; |
| } |
| [data-testid="stSidebar"] .stTextInput input:focus { |
| border-color: var(--accent) !important; |
| box-shadow: 0 0 0 2px rgba(0,201,167,0.15) !important; |
| } |
| [data-testid="stSidebar"] .stTextInput label { display: none !important; } |
| [data-testid="stSidebar"] .stButton > button { |
| background: transparent !important; |
| border: 1px solid var(--border) !important; |
| color: var(--muted) !important; |
| border-radius: 8px !important; |
| width: 100% !important; |
| font-size: 11px !important; |
| padding: 5px 10px !important; |
| transition: all 0.15s !important; |
| font-family: 'Plus Jakarta Sans', sans-serif !important; |
| } |
| [data-testid="stSidebar"] .stButton > button:hover { |
| border-color: var(--red) !important; |
| color: var(--red) !important; |
| } |
| |
| /* ββ Sidebar blocks ββ */ |
| .sb-logo { |
| padding: 18px 16px 14px; |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| .sb-brand { |
| display: flex; align-items: center; gap: 9px; |
| } |
| .sb-icon { |
| width: 30px; height: 30px; |
| background: linear-gradient(135deg, var(--accent), #007a66); |
| border-radius: 8px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 14px; |
| } |
| .sb-name { |
| font-family: 'JetBrains Mono', monospace; |
| font-weight: 700; |
| font-size: 15px; |
| color: var(--text) !important; |
| letter-spacing: -0.5px; |
| } |
| .sb-tag { |
| font-size: 9px; |
| font-family: 'JetBrains Mono', monospace; |
| color: var(--accent) !important; |
| background: rgba(0,201,167,0.1); |
| border: 1px solid rgba(0,201,167,0.2); |
| border-radius: 4px; |
| padding: 2px 6px; |
| letter-spacing: 0.5px; |
| } |
| .sb-sec { |
| padding: 14px 14px 0; |
| } |
| .sb-sec-label { |
| font-size: 9px !important; |
| letter-spacing: 1.2px; |
| text-transform: uppercase; |
| color: var(--muted) !important; |
| font-weight: 700; |
| margin-bottom: 8px; |
| display: block; |
| } |
| .sb-divider { height: 1px; background: var(--border); margin: 14px 0 0; } |
| |
| /* ββ Provider toggle ββ */ |
| .provider-bar { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 6px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| padding: 4px; |
| } |
| .provider-btn { |
| padding: 7px 10px; |
| border-radius: 7px; |
| font-size: 11px; |
| font-weight: 600; |
| text-align: center; |
| cursor: pointer; |
| transition: all 0.15s; |
| color: var(--muted) !important; |
| } |
| .provider-btn.active { |
| background: var(--accent-dim); |
| color: var(--accent) !important; |
| border: 1px solid rgba(0,201,167,0.3); |
| } |
| |
| /* ββ Doc cards ββ */ |
| .doc-card { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 11px; |
| padding: 10px 12px; |
| margin-bottom: 6px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| position: relative; |
| } |
| .doc-card.active { border-color: rgba(0,201,167,0.4); background: rgba(0,201,167,0.04); } |
| .doc-card-icon { |
| width: 30px; height: 30px; |
| background: rgba(0,201,167,0.1); |
| border-radius: 8px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 12px; flex-shrink: 0; |
| } |
| .doc-card-info { flex: 1; min-width: 0; } |
| .doc-card-name { |
| font-size: 11px; font-weight: 600; |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| color: var(--text) !important; |
| } |
| .doc-card-meta { font-size: 9px; color: var(--muted) !important; margin-top: 2px; } |
| .doc-badge { |
| font-size: 8px; font-family: 'JetBrains Mono', monospace; |
| background: rgba(0,201,167,0.1); |
| color: var(--accent) !important; |
| padding: 2px 5px; border-radius: 3px; |
| border: 1px solid rgba(0,201,167,0.2); |
| } |
| |
| /* ββ Stats row ββ */ |
| .stats-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5px; margin-top: 8px; } |
| .stat-box { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 9px; |
| padding: 8px 6px; |
| text-align: center; |
| } |
| .stat-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 15px; font-weight: 700; |
| color: var(--accent) !important; line-height: 1; |
| } |
| .stat-key { font-size: 8px; color: var(--muted) !important; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 3px; } |
| |
| /* ββ Main ββ */ |
| .main-wrap { max-width: 760px; margin: 0 auto; padding: 44px 24px 140px; } |
| |
| /* ββ Hero ββ */ |
| .hero { padding: 20px 0 52px; } |
| .hero-eyebrow { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; letter-spacing: 2px; text-transform: uppercase; |
| color: var(--accent) !important; |
| display: flex; align-items: center; gap: 8px; |
| margin-bottom: 24px; |
| } |
| .hero-eyebrow::before { |
| content: ''; |
| width: 24px; height: 1px; |
| background: var(--accent); |
| display: inline-block; |
| } |
| .hero-title { |
| font-family: 'Plus Jakarta Sans', sans-serif; |
| font-size: 56px; font-weight: 800; |
| line-height: 1.0; letter-spacing: -3px; |
| color: var(--text); margin-bottom: 20px; |
| } |
| .hero-title .dim { color: var(--muted); font-weight: 300; } |
| .hero-title .hl { |
| background: linear-gradient(90deg, var(--accent2), var(--blue)); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| } |
| .hero-sub { |
| font-size: 15px; color: var(--muted); line-height: 1.8; |
| max-width: 400px; font-weight: 400; margin-bottom: 40px; |
| } |
| .step-cards { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; max-width: 460px; } |
| .step-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 14px; padding: 16px 14px; |
| transition: border-color 0.2s; |
| } |
| .step-card:hover { border-color: var(--border2); } |
| .step-num { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; color: var(--accent) !important; |
| letter-spacing: 1px; margin-bottom: 10px; |
| } |
| .step-title { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 4px; } |
| .step-desc { font-size: 11px; color: var(--muted); line-height: 1.5; } |
| |
| /* ββ Summary box ββ */ |
| .summary-box { |
| background: var(--surface); |
| border: 1px solid rgba(0,201,167,0.2); |
| border-radius: 14px; |
| padding: 18px 20px; |
| margin-bottom: 28px; |
| position: relative; |
| } |
| .summary-box::before { |
| content: ''; |
| position: absolute; left: 0; top: 16px; bottom: 16px; |
| width: 3px; background: var(--accent); border-radius: 0 2px 2px 0; |
| } |
| .summary-title { |
| font-size: 10px; font-family: 'JetBrains Mono', monospace; |
| color: var(--accent) !important; letter-spacing: 1.5px; |
| text-transform: uppercase; margin-bottom: 10px; |
| display: flex; align-items: center; gap: 7px; |
| } |
| .summary-content { font-size: 13px; color: var(--text); line-height: 1.7; } |
| |
| /* ββ Suggestions ββ */ |
| .sugg-header { |
| font-size: 10px; font-family: 'JetBrains Mono', monospace; |
| color: var(--muted); letter-spacing: 1.5px; text-transform: uppercase; |
| display: flex; align-items: center; gap: 12px; |
| margin-bottom: 12px; |
| } |
| .sugg-header::after { content: ''; flex: 1; height: 1px; background: var(--border); } |
| |
| .stButton > button { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| color: var(--muted) !important; |
| border-radius: 10px !important; |
| padding: 12px 14px !important; |
| font-size: 12px !important; |
| font-family: 'Plus Jakarta Sans', sans-serif !important; |
| text-align: left !important; |
| width: 100% !important; |
| line-height: 1.5 !important; |
| height: auto !important; |
| white-space: normal !important; |
| transition: all 0.15s !important; |
| } |
| .stButton > button:hover { |
| background: var(--surface2) !important; |
| border-color: var(--accent) !important; |
| color: var(--text) !important; |
| transform: translateY(-1px) !important; |
| } |
| |
| /* ββ Chat ββ */ |
| .chat-wrap { display: flex; flex-direction: column; gap: 0; } |
| @keyframes rise { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .msg-row-user { |
| display: flex; justify-content: flex-end; gap: 10px; align-items: flex-end; |
| animation: rise 0.22s ease; margin-bottom: 8px; |
| } |
| .msg-row-ai { |
| display: flex; justify-content: flex-start; gap: 10px; align-items: flex-start; |
| animation: rise 0.22s ease; margin-bottom: 6px; |
| } |
| .bubble-user { |
| background: linear-gradient(135deg, #007a66, var(--accent)); |
| color: #fff; |
| padding: 12px 18px; |
| border-radius: 18px 18px 4px 18px; |
| font-size: 14px; line-height: 1.65; max-width: 70%; |
| font-weight: 500; |
| } |
| .bubble-ai { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 16px 20px; |
| border-radius: 4px 18px 18px 18px; |
| font-size: 14px; line-height: 1.8; max-width: 82%; |
| } |
| .av { |
| width: 30px; height: 30px; border-radius: 50%; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 13px; flex-shrink: 0; |
| } |
| .av-ai { background: var(--surface2); border: 1px solid var(--border2); } |
| .av-usr { |
| background: linear-gradient(135deg, #007a66, var(--accent)); |
| color: white; font-size: 11px; font-weight: 700; |
| } |
| |
| /* ββ Citation cards ββ */ |
| .citations-row { |
| display: flex; flex-wrap: wrap; gap: 6px; |
| margin-left: 40px; margin-bottom: 24px; |
| animation: rise 0.3s ease; |
| } |
| .cite-card { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 5px 10px; |
| font-size: 10px; |
| font-family: 'JetBrains Mono', monospace; |
| color: var(--muted) !important; |
| display: flex; align-items: center; gap: 5px; |
| } |
| .cite-card .cite-page { |
| color: var(--accent) !important; |
| font-weight: 700; |
| } |
| .cite-card .cite-doc { |
| color: var(--muted) !important; |
| max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| .cite-score { |
| background: rgba(0,201,167,0.1); |
| color: var(--accent) !important; |
| padding: 1px 5px; border-radius: 3px; font-size: 9px; |
| } |
| |
| /* ββ Export button ββ */ |
| .export-btn { |
| font-size: 10px; font-family: 'JetBrains Mono', monospace; |
| color: var(--muted) !important; cursor: pointer; |
| text-decoration: underline; text-underline-offset: 3px; |
| } |
| |
| /* ββ Chat input ββ */ |
| [data-testid="stChatInput"] { |
| background: var(--surface) !important; |
| border: 1px solid var(--border2) !important; |
| border-radius: 14px !important; |
| box-shadow: 0 0 0 1px var(--border) !important; |
| } |
| [data-testid="stChatInput"] textarea { |
| background: var(--surface) !important; |
| border: none !important; |
| color: var(--text) !important; |
| font-family: 'Plus Jakarta Sans', sans-serif !important; |
| font-size: 14px !important; |
| } |
| [data-testid="stChatInput"] textarea::placeholder { color: var(--muted) !important; } |
| [data-testid="stChatInput"] button { |
| background: var(--accent) !important; |
| border-radius: 10px !important; |
| } |
| |
| /* ββ Misc ββ */ |
| .stSpinner > div { border-top-color: var(--accent) !important; } |
| ::-webkit-scrollbar { width: 4px; } |
| ::-webkit-scrollbar-track { background: var(--bg); } |
| ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; } |
| [data-testid="stExpander"] { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: 12px !important; |
| } |
| [data-testid="stExpander"] summary { color: var(--muted) !important; font-size: 12px !important; } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| @st.cache_resource(show_spinner=False) |
| def load_embedder(): |
| from sentence_transformers import SentenceTransformer |
| return SentenceTransformer('all-MiniLM-L6-v2') |
|
|
|
|
| |
| def extract_pdf(file, doc_name: str): |
| file.seek(0) |
| reader = pypdf.PdfReader(file) |
| chunks = [] |
| full_text = "" |
| CHUNK_SIZE, OVERLAP = 280, 55 |
|
|
| for page_idx, page in enumerate(reader.pages): |
| page_text = page.extract_text() or "" |
| full_text += page_text + "\n" |
| words = page_text.split() |
| start = 0 |
| while start < len(words): |
| chunk_text = " ".join(words[start: start + CHUNK_SIZE]) |
| if chunk_text.strip(): |
| chunks.append({ |
| "text": chunk_text, |
| "page": page_idx + 1, |
| "doc": doc_name, |
| }) |
| start += CHUNK_SIZE - OVERLAP |
|
|
| meta = { |
| "pages": len(reader.pages), |
| "words": len(full_text.split()), |
| "chunks": len(chunks), |
| "full_text": full_text[:5000], |
| } |
| return chunks, meta |
|
|
|
|
| |
| def embed_chunks(chunks): |
| model = load_embedder() |
| texts = [c["text"] for c in chunks] |
| return model.encode(texts, show_progress_bar=False, batch_size=32) |
|
|
|
|
| def semantic_search(query: str, all_chunks, all_embeddings, k=5, doc_filter=None): |
| from sklearn.metrics.pairwise import cosine_similarity |
| if all_embeddings is None or len(all_chunks) == 0: |
| return [] |
| model = load_embedder() |
| q_emb = model.encode([query]) |
|
|
| if doc_filter: |
| indices = [i for i, c in enumerate(all_chunks) if c["doc"] in doc_filter] |
| else: |
| indices = list(range(len(all_chunks))) |
|
|
| if not indices: |
| return [] |
|
|
| filtered_embs = all_embeddings[indices] |
| scores = cosine_similarity(q_emb, filtered_embs)[0] |
| top_local = np.argsort(scores)[::-1][:k] |
|
|
| results = [] |
| for local_idx in top_local: |
| global_idx = indices[local_idx] |
| results.append({ |
| "text": all_chunks[global_idx]["text"], |
| "page": all_chunks[global_idx]["page"], |
| "doc": all_chunks[global_idx]["doc"], |
| "score": float(scores[local_idx]), |
| }) |
| return results |
|
|
|
|
| |
| def _build_messages(system, user_msg, history): |
| msgs = [{"role": "system", "content": system}] |
| for h in history[-4:]: |
| msgs.append({"role": "user", "content": h["q"]}) |
| msgs.append({"role": "assistant", "content": h["a"]}) |
| msgs.append({"role": "user", "content": user_msg}) |
| return msgs |
|
|
|
|
| def call_groq(api_key: str, system: str, user_msg: str, history): |
| try: |
| from groq import Groq |
| client = Groq(api_key=api_key) |
| r = client.chat.completions.create( |
| model="llama-3.1-8b-instant", |
| messages=_build_messages(system, user_msg, history), |
| max_tokens=1024, temperature=0.3, |
| ) |
| return r.choices[0].message.content.strip() |
| except Exception as e: |
| err = str(e) |
| if "429" in err: return "β οΈ Rate limit β wait a moment and retry." |
| if "401" in err: return "β οΈ Invalid Groq API key. Check sidebar." |
| return f"β οΈ Groq error: {err}" |
|
|
|
|
| def call_gemini(api_key: str, system: str, user_msg: str, history): |
| try: |
| import google.generativeai as genai |
| genai.configure(api_key=api_key) |
| model = genai.GenerativeModel( |
| model_name="gemini-1.5-flash", |
| system_instruction=system, |
| ) |
| hist = [] |
| for h in history[-4:]: |
| hist.append({"role": "user", "parts": [h["q"]]}) |
| hist.append({"role": "model", "parts": [h["a"]]}) |
| chat = model.start_chat(history=hist) |
| return chat.send_message(user_msg).text |
| except Exception as e: |
| err = str(e) |
| if "API_KEY" in err or "invalid" in err.lower(): |
| return "β οΈ Invalid Gemini API key. Check sidebar." |
| return f"β οΈ Gemini error: {err}" |
|
|
|
|
| def call_llm(provider, keys, system, user_msg, history): |
| if provider == "Groq": |
| return call_groq(keys.get("groq", ""), system, user_msg, history) |
| return call_gemini(keys.get("gemini", ""), system, user_msg, history) |
|
|
|
|
| |
| def get_answer(provider, keys, results, question, history): |
| context_parts = [] |
| for r in results: |
| score_pct = int(r["score"] * 100) |
| context_parts.append( |
| f"[Source: {r['doc']} | Page {r['page']} | Relevance: {score_pct}%]\n{r['text']}" |
| ) |
| context = "\n\n---\n\n".join(context_parts) |
|
|
| system = ( |
| "You are Nexus, a precise AI document assistant.\n" |
| "Rules:\n" |
| "- Answer ONLY from the provided context. Never fabricate information.\n" |
| "- If the answer is not in the context, say: 'This information is not available in the uploaded documents.'\n" |
| "- Always cite the source page when referencing specific facts (e.g. 'According to Page 4...').\n" |
| "- Use markdown formatting: **bold** for key terms, bullet lists for multi-point answers.\n" |
| "- Be concise but complete." |
| ) |
| user_msg = f"RETRIEVED CONTEXT:\n{context}\n\nQUESTION: {question}" |
| return call_llm(provider, keys, system, user_msg, history) |
|
|
|
|
| |
| def get_summary(provider, keys, full_text): |
| system = "You are a document analyst. Be structured and concise." |
| user_msg = ( |
| "Provide a structured document summary:\n\n" |
| "**Main Topic:** (1 sentence)\n" |
| "**Key Points:** (4 bullet points)\n" |
| "**Important Data/Facts:** (3 bullet points)\n" |
| "**Conclusion:** (1-2 sentences)\n\n" |
| f"Document:\n{full_text[:4500]}" |
| ) |
| return call_llm(provider, keys, system, user_msg, []) |
|
|
|
|
| |
| def get_suggestions(provider, keys, full_text): |
| system = 'Output ONLY a Python list of 4 strings. No markdown, no explanation, no preamble.' |
| user_msg = f'4 insightful questions for this document. Format exactly: ["Q1?","Q2?","Q3?","Q4?"]\n\n{full_text[:2500]}' |
| raw = call_llm(provider, keys, system, user_msg, []) |
| try: |
| match = re.search(r'\[.*?\]', raw, re.DOTALL) |
| if match: |
| qs = ast.literal_eval(match.group()) |
| if isinstance(qs, list) and len(qs) >= 4: |
| return qs[:4] |
| except Exception: |
| pass |
| return [ |
| "What is the main topic of this document?", |
| "What are the key findings or conclusions?", |
| "What data or evidence is presented?", |
| "What are the recommendations or next steps?", |
| ] |
|
|
|
|
| |
| def build_export(history, doc_names): |
| lines = [ |
| "NEXUS AI β CHAT EXPORT", |
| "=" * 50, |
| f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}", |
| f"Documents: {', '.join(doc_names)}", |
| "=" * 50, |
| "", |
| ] |
| for i, turn in enumerate(history, 1): |
| lines.append(f"Q{i}: {turn['q']}") |
| lines.append(f"A{i}: {turn['a']}") |
| if turn.get("citations"): |
| cites = ", ".join( |
| f"p.{c['page']} in '{c['doc']}' ({int(c['score']*100)}%)" |
| for c in turn["citations"] |
| ) |
| lines.append(f"Sources: {cites}") |
| lines.append("") |
| return "\n".join(lines) |
|
|
|
|
| |
| _defaults = { |
| "history": [], |
| "all_chunks": [], |
| "all_embeddings": None, |
| "docs": {}, |
| "summaries": {}, |
| "suggestions": [], |
| "prefill": "", |
| "provider": "Groq", |
| "doc_filter": None, |
| } |
| for k, v in _defaults.items(): |
| if k not in st.session_state: |
| st.session_state[k] = v |
|
|
| |
| _env_groq = os.environ.get("GROQ_API_KEY", "") |
| _env_gemini = os.environ.get("GEMINI_API_KEY", "") |
|
|
| if "groq_key" not in st.session_state: st.session_state.groq_key = _env_groq |
| if "gemini_key" not in st.session_state: st.session_state.gemini_key = _env_gemini |
|
|
|
|
| def keys(): |
| return {"groq": st.session_state.groq_key, "gemini": st.session_state.gemini_key} |
|
|
|
|
| def active_key_ok(): |
| p = st.session_state.provider |
| k = keys() |
| return bool(k["groq"] if p == "Groq" else k["gemini"]) |
|
|
|
|
| |
| |
| |
| with st.sidebar: |
|
|
| |
| st.markdown(""" |
| <div class="sb-logo"> |
| <div class="sb-brand"> |
| <div class="sb-icon">β‘</div> |
| <span class="sb-name">Nexus</span> |
| </div> |
| <span class="sb-tag">v2.0</span> |
| </div>""", unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="sb-sec"><span class="sb-sec-label">AI Provider</span>', unsafe_allow_html=True) |
| provider = st.radio( |
| "provider", ["Groq", "Gemini"], |
| horizontal=True, |
| index=0 if st.session_state.provider == "Groq" else 1, |
| label_visibility="collapsed", |
| ) |
| st.session_state.provider = provider |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="sb-sec"><span class="sb-sec-label">API Key</span>', unsafe_allow_html=True) |
| if provider == "Groq": |
| k = st.text_input("groq_key", value=st.session_state.groq_key, |
| placeholder="gsk_...", type="password", |
| label_visibility="collapsed") |
| st.session_state.groq_key = k |
| else: |
| k = st.text_input("gemini_key", value=st.session_state.gemini_key, |
| placeholder="AIza...", type="password", |
| label_visibility="collapsed") |
| st.session_state.gemini_key = k |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="sb-divider"></div>', unsafe_allow_html=True) |
| st.markdown('<div class="sb-sec"><span class="sb-sec-label">Upload PDF</span>', unsafe_allow_html=True) |
|
|
| uploaded = st.file_uploader("pdf", type="pdf", label_visibility="collapsed") |
|
|
| if uploaded: |
| if not active_key_ok(): |
| st.error("Add your API key above first.") |
| elif uploaded.name not in st.session_state.docs: |
| with st.spinner("Indexingβ¦"): |
| new_chunks, meta = extract_pdf(uploaded, uploaded.name) |
| new_embs = embed_chunks(new_chunks) |
|
|
| st.session_state.all_chunks.extend(new_chunks) |
| if st.session_state.all_embeddings is None: |
| st.session_state.all_embeddings = new_embs |
| else: |
| st.session_state.all_embeddings = np.vstack( |
| [st.session_state.all_embeddings, new_embs] |
| ) |
| st.session_state.docs[uploaded.name] = meta |
|
|
| |
| summary = get_summary(provider, keys(), meta["full_text"]) |
| st.session_state.summaries[uploaded.name] = summary |
|
|
| |
| st.session_state.suggestions = get_suggestions( |
| provider, keys(), meta["full_text"] |
| ) |
| st.session_state.history = [] |
|
|
| st.success(f"β Indexed {meta['pages']} pages") |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| if st.session_state.docs: |
| st.markdown('<div class="sb-divider"></div>', unsafe_allow_html=True) |
| st.markdown('<div class="sb-sec"><span class="sb-sec-label">Documents</span>', unsafe_allow_html=True) |
|
|
| for doc_name, meta in list(st.session_state.docs.items()): |
| words_display = ( |
| f"{meta['words']:,}" if meta["words"] < 10000 |
| else f"{round(meta['words']/1000,1)}k" |
| ) |
| short_name = doc_name if len(doc_name) <= 22 else doc_name[:19] + "β¦" |
| st.markdown(f""" |
| <div class="doc-card"> |
| <div class="doc-card-icon">π</div> |
| <div class="doc-card-info"> |
| <div class="doc-card-name">{short_name}</div> |
| <div class="doc-card-meta">{meta['pages']} pages Β· {words_display} words</div> |
| </div> |
| <span class="doc-badge">{meta['chunks']}c</span> |
| </div>""", unsafe_allow_html=True) |
|
|
| total_pages = sum(m["pages"] for m in st.session_state.docs.values()) |
| total_words = sum(m["words"] for m in st.session_state.docs.values()) |
| total_chunks = sum(m["chunks"] for m in st.session_state.docs.values()) |
| tw = f"{round(total_words/1000,1)}k" if total_words >= 1000 else str(total_words) |
| st.markdown(f""" |
| <div class="stats-row"> |
| <div class="stat-box"><div class="stat-val">{len(st.session_state.docs)}</div><div class="stat-key">docs</div></div> |
| <div class="stat-box"><div class="stat-val">{total_pages}</div><div class="stat-key">pages</div></div> |
| <div class="stat-box"><div class="stat-val">{tw}</div><div class="stat-key">words</div></div> |
| </div>""", unsafe_allow_html=True) |
|
|
| st.markdown('<br>', unsafe_allow_html=True) |
| if st.button("π Clear all documents", use_container_width=True): |
| for k_s in ["history", "all_chunks", "suggestions", "summaries"]: |
| st.session_state[k_s] = [] if k_s != "summaries" else {} |
| st.session_state.all_embeddings = None |
| st.session_state.docs = {} |
| st.rerun() |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.markdown(""" |
| <div style="padding: 10px 14px; border-top: 1px solid var(--border); margin-top: 16px;"> |
| <div style="font-size:9px; color: var(--muted); font-family:'JetBrains Mono',monospace; line-height:1.6;"> |
| Groq Β· Llama 3.1 8B Instant<br> |
| Gemini Β· 1.5 Flash<br> |
| Embeddings Β· all-MiniLM-L6-v2 |
| </div> |
| </div>""", unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
| st.markdown('<div class="main-wrap">', unsafe_allow_html=True) |
|
|
| |
| if not st.session_state.docs: |
| st.markdown(""" |
| <div class="hero"> |
| <div class="hero-eyebrow">Semantic Document Intelligence</div> |
| <div class="hero-title"> |
| Talk to <span class="hl">any PDF.</span><br> |
| <span class="dim">Instantly.</span> |
| </div> |
| <div class="hero-sub"> |
| Upload documents and get precise, cited answers powered by |
| real semantic search β not keyword guessing. |
| </div> |
| <div class="step-cards"> |
| <div class="step-card"> |
| <div class="step-num">01 /</div> |
| <div class="step-title">Upload</div> |
| <div class="step-desc">Drop one or multiple PDFs</div> |
| </div> |
| <div class="step-card"> |
| <div class="step-num">02 /</div> |
| <div class="step-title">Index</div> |
| <div class="step-desc">Semantic vectors built instantly</div> |
| </div> |
| <div class="step-card"> |
| <div class="step-num">03 /</div> |
| <div class="step-title">Ask</div> |
| <div class="step-desc">Get answers with page citations</div> |
| </div> |
| </div> |
| </div>""", unsafe_allow_html=True) |
|
|
| else: |
| |
| for doc_name, summary in st.session_state.summaries.items(): |
| short = doc_name if len(doc_name) <= 30 else doc_name[:27] + "β¦" |
| with st.expander(f"π Auto-summary β {short}", expanded=False): |
| st.markdown(f""" |
| <div class="summary-box"> |
| <div class="summary-title">β‘ Document Summary</div> |
| <div class="summary-content">{summary}</div> |
| </div>""", unsafe_allow_html=True) |
|
|
| |
| if not st.session_state.history and st.session_state.suggestions: |
| st.markdown('<div class="sugg-header">Suggested questions</div>', unsafe_allow_html=True) |
| cols = st.columns(2) |
| for i, q in enumerate(st.session_state.suggestions): |
| with cols[i % 2]: |
| if st.button(q, key=f"sq_{i}"): |
| st.session_state.prefill = q |
| st.rerun() |
| st.markdown("<br>", unsafe_allow_html=True) |
|
|
| |
| if st.session_state.history: |
| st.markdown('<div class="chat-wrap">', unsafe_allow_html=True) |
| for turn in st.session_state.history: |
| |
| st.markdown(f""" |
| <div class="msg-row-user"> |
| <div class="bubble-user">{turn['q']}</div> |
| <div class="av av-usr">YOU</div> |
| </div>""", unsafe_allow_html=True) |
|
|
| |
| st.markdown(f""" |
| <div class="msg-row-ai"> |
| <div class="av av-ai">β‘</div> |
| <div class="bubble-ai">{turn['a']}</div> |
| </div>""", unsafe_allow_html=True) |
|
|
| |
| if turn.get("citations"): |
| cite_html = "" |
| for c in turn["citations"]: |
| score_pct = int(c["score"] * 100) |
| doc_short = c["doc"][:18] + "β¦" if len(c["doc"]) > 20 else c["doc"] |
| cite_html += f""" |
| <div class="cite-card"> |
| <span>π</span> |
| <span class="cite-doc">{doc_short}</span> |
| <span class="cite-page">p.{c['page']}</span> |
| <span class="cite-score">{score_pct}%</span> |
| </div>""" |
| st.markdown(f'<div class="citations-row">{cite_html}</div>', unsafe_allow_html=True) |
|
|
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| export_text = build_export(st.session_state.history, list(st.session_state.docs.keys())) |
| st.download_button( |
| "β Export chat", |
| data=export_text, |
| file_name=f"nexus_chat_{datetime.now().strftime('%Y%m%d_%H%M')}.txt", |
| mime="text/plain", |
| ) |
|
|
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| if st.session_state.docs: |
| placeholder = ( |
| f"Ask across {len(st.session_state.docs)} document(s)β¦" |
| if len(st.session_state.docs) > 1 |
| else "Ask anything about your documentβ¦" |
| ) |
| question = st.chat_input(placeholder) |
|
|
| |
| if st.session_state.prefill and not question: |
| question = st.session_state.prefill |
| st.session_state.prefill = "" |
|
|
| if question: |
| if not active_key_ok(): |
| st.error("Please add your API key in the sidebar.") |
| else: |
| with st.spinner("Searching & reasoningβ¦"): |
| results = semantic_search( |
| question, |
| st.session_state.all_chunks, |
| st.session_state.all_embeddings, |
| k=5, |
| doc_filter=st.session_state.doc_filter, |
| ) |
| answer = get_answer( |
| st.session_state.provider, keys(), |
| results, question, st.session_state.history, |
| ) |
| st.session_state.history.append({ |
| "q": question, |
| "a": answer, |
| "citations": results, |
| }) |
| st.rerun() |
| else: |
| st.chat_input("Upload a PDF to beginβ¦", disabled=True) |
|
|