github-actions[bot]
Sync from GitHub 907b7448edad59db074f2417e42629ba5c3f1cc7
dde0c6d
from __future__ import annotations
import os
import time
from typing import Any
import requests
import streamlit as st
st.set_page_config(page_title="NotebookLM Clone", page_icon="📚", layout="wide")
BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
REQUEST_TIMEOUT_SECONDS = 30
STATUS_LABELS = {
"ready": "Ready",
"failed": "Failed",
"processing": "Processing",
"pending": "Pending",
}
STATUS_ICONS = {
"ready": "✓",
"failed": "!",
"processing": "~",
"pending": "…",
}
def get_http_session() -> requests.Session:
session = st.session_state.get("http_session")
if isinstance(session, requests.Session):
return session
session = requests.Session()
st.session_state["http_session"] = session
return session
def api_request(
method: str,
path: str,
*,
params: dict | None = None,
json_payload: dict | None = None,
data: dict[str, str | int] | None = None,
files: dict[str, tuple[str, bytes, str]] | None = None,
) -> tuple[bool, dict | list | str, int | None]:
base_url = st.session_state.get("backend_url", BACKEND_URL).rstrip("/")
url = f"{base_url}{path}"
try:
response = get_http_session().request(
method=method,
url=url,
params=params,
json=json_payload,
data=data,
files=files,
timeout=REQUEST_TIMEOUT_SECONDS,
)
status_code = response.status_code
if 200 <= status_code < 300:
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
return True, response.json(), status_code
text_body = response.text.strip()
return True, (text_body if text_body else "ok"), status_code
try:
error_body: dict | list | str = response.json()
except ValueError:
error_body = response.text
return False, f"HTTP {status_code}: {error_body}", status_code
except requests.RequestException as exc:
return False, str(exc), None
def api_get(path: str, params: dict | None = None) -> tuple[bool, dict | list | str, int | None]:
return api_request("GET", path, params=params)
def api_post(path: str, payload: dict) -> tuple[bool, dict | list | str, int | None]:
return api_request("POST", path, json_payload=payload)
def api_patch(path: str, payload: dict) -> tuple[bool, dict | list | str, int | None]:
return api_request("PATCH", path, json_payload=payload)
def api_delete(path: str) -> tuple[bool, dict | list | str, int | None]:
return api_request("DELETE", path)
def api_get_bytes(path: str) -> tuple[bool, bytes | str, int | None]:
base_url = st.session_state.get("backend_url", BACKEND_URL).rstrip("/")
url = f"{base_url}{path}"
try:
response = get_http_session().request(
method="GET",
url=url,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
return False, str(exc), None
if 200 <= response.status_code < 300:
return True, response.content, response.status_code
return False, f"HTTP {response.status_code}: {response.text}", response.status_code
def api_post_multipart(
path: str,
data: dict[str, str | int],
files: dict[str, tuple[str, bytes, str]],
) -> tuple[bool, dict | list | str, int | None]:
return api_request("POST", path, data=data, files=files)
def request_backend_get_raw(
path: str,
*,
params: dict | None = None,
allow_redirects: bool = True,
) -> tuple[bool, requests.Response | str]:
base_url = st.session_state.get("backend_url", BACKEND_URL).rstrip("/")
url = f"{base_url}{path}"
try:
response = get_http_session().request(
method="GET",
url=url,
params=params,
timeout=REQUEST_TIMEOUT_SECONDS,
allow_redirects=allow_redirects,
)
return True, response
except requests.RequestException as exc:
return False, str(exc)
def begin_hf_oauth() -> tuple[bool, str]:
ok, response_or_error = request_backend_get_raw("/auth/login", allow_redirects=False)
if not ok:
return False, str(response_or_error)
response = response_or_error
if not isinstance(response, requests.Response):
return False, "Unexpected OAuth start response."
if response.status_code not in {301, 302, 303, 307, 308}:
return False, f"OAuth start failed with HTTP {response.status_code}: {response.text}"
location = response.headers.get("location", "").strip()
if not location:
return False, "OAuth start failed: missing redirect location."
return True, location
def complete_hf_oauth(code: str, state: str) -> tuple[bool, str | None]:
ok, response_or_error = request_backend_get_raw(
"/auth/callback",
params={"code": code, "state": state},
allow_redirects=False,
)
if not ok:
return False, str(response_or_error)
response = response_or_error
if not isinstance(response, requests.Response):
return False, "Unexpected OAuth callback response."
if response.status_code in {301, 302, 303, 307, 308}:
return True, None
return False, f"OAuth callback failed with HTTP {response.status_code}: {response.text}"
def fetch_notebooks() -> tuple[bool, list[dict] | str]:
ok, result, _ = api_get("/notebooks")
if not ok:
return False, str(result)
notebooks = result if isinstance(result, list) else []
return True, notebooks
def get_query_param(name: str) -> str | None:
try:
value = st.query_params.get(name)
except Exception:
params = st.experimental_get_query_params()
raw = params.get(name)
if isinstance(raw, list):
return str(raw[0]) if raw else None
if raw is None:
return None
return str(raw)
if isinstance(value, list):
return str(value[0]) if value else None
if value is None:
return None
return str(value)
def remove_query_param(name: str) -> None:
try:
if name in st.query_params:
del st.query_params[name]
return
except Exception:
pass
params = st.experimental_get_query_params()
if name in params:
params.pop(name, None)
st.experimental_set_query_params(**params)
def inject_theme() -> None:
st.markdown(
"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
/* ── Light-mode tokens (default) ── */
:root {
--ink: #162525;
--ink-muted: #4f6362;
--card: #fefdf9;
--card-border: #d2dad3;
--accent: #146f67;
--accent-soft: #d6efeb;
--warn-soft: #fff2dd;
--warn-border: #f0bd69;
--ok-soft: #def5ec;
--ok-border: #53a27f;
--error-soft: #ffe6e3;
--error-border: #d2675a;
--pending-soft: #eceeef;
--pending-border: #9aa7ad;
--app-bg: linear-gradient(180deg, #f8faf8 0%, #f1f5f2 100%);
--app-bg-overlay-1: radial-gradient(circle at 15% -5%, #fff5e6 0%, rgba(255, 245, 230, 0) 42%);
--app-bg-overlay-2: radial-gradient(circle at 88% 8%, #dff2ee 0%, rgba(223, 242, 238, 0) 44%);
--hero-bg: linear-gradient(125deg, rgba(20, 111, 103, 0.13) 0%, rgba(255, 245, 228, 0.95) 56%, rgba(205, 231, 226, 0.9) 100%);
--hero-border: #c9d9cf;
--hero-kicker-bg: rgba(255, 255, 255, 0.7);
--hero-kicker-color: #0f625b;
--hero-kicker-border: rgba(15, 98, 91, 0.22);
--soft-card-bg: rgba(255, 255, 255, 0.82);
--sidebar-bg: linear-gradient(180deg, #173436 0%, #11292b 100%);
--sidebar-text: #e9f1ef;
--sidebar-input-bg: rgba(233, 241, 239, 0.1);
--sidebar-input-border: rgba(178, 209, 202, 0.35);
--sidebar-btn-bg: linear-gradient(135deg, #ecf8f4 0%, #d8efe9 100%);
--sidebar-btn-color: #123f3b;
--sidebar-btn-border: #90c6bb;
--sidebar-btn-hover-bg: linear-gradient(135deg, #f5fffc 0%, #e3f8f3 100%);
--btn-bg: linear-gradient(135deg, #167269 0%, #0f5f57 100%);
--btn-color: #f4fffd;
--btn-border: #0f5f57;
--btn-shadow: rgba(15, 95, 87, 0.22);
--input-color: #162525;
--input-placeholder: #5a6a69;
--select-dropdown-color: #0f2a27;
--status-ready-color: #1c6b4d;
--status-failed-color: #8d3329;
--status-processing-color: #7d581a;
--status-pending-color: #485a63;
}
/* ── Dark-mode tokens ── */
/* Triggered by OS preference OR by JS-injected .dark-mode class */
@media (prefers-color-scheme: dark) {
:root {
--ink: #e4eceb;
--ink-muted: #9bb0ae;
--card: #1e2d2d;
--card-border: #3a4e4b;
--accent: #3dd4c6;
--accent-soft: #1a3d39;
--warn-soft: #3d3223;
--warn-border: #b89040;
--ok-soft: #1a3d2e;
--ok-border: #3d9a6a;
--error-soft: #3d2220;
--error-border: #c25a4e;
--pending-soft: #2a3235;
--pending-border: #6b7b82;
--app-bg: linear-gradient(180deg, #141e1e 0%, #0f1817 100%);
--app-bg-overlay-1: radial-gradient(circle at 15% -5%, rgba(50, 40, 20, 0.4) 0%, rgba(50, 40, 20, 0) 42%);
--app-bg-overlay-2: radial-gradient(circle at 88% 8%, rgba(20, 60, 55, 0.3) 0%, rgba(20, 60, 55, 0) 44%);
--hero-bg: linear-gradient(125deg, rgba(20, 111, 103, 0.2) 0%, rgba(30, 45, 45, 0.95) 56%, rgba(25, 60, 55, 0.9) 100%);
--hero-border: #2a3e3c;
--hero-kicker-bg: rgba(30, 45, 45, 0.7);
--hero-kicker-color: #5ce0d3;
--hero-kicker-border: rgba(60, 180, 165, 0.3);
--soft-card-bg: rgba(30, 45, 45, 0.7);
--sidebar-bg: linear-gradient(180deg, #0e1f20 0%, #0a1617 100%);
--sidebar-text: #c8dad6;
--sidebar-input-bg: rgba(200, 218, 214, 0.08);
--sidebar-input-border: rgba(120, 170, 160, 0.25);
--sidebar-btn-bg: linear-gradient(135deg, #1a3332 0%, #162c2b 100%);
--sidebar-btn-color: #b0d8cf;
--sidebar-btn-border: #3a6e64;
--sidebar-btn-hover-bg: linear-gradient(135deg, #1f3d3b 0%, #1a3634 100%);
--btn-bg: linear-gradient(135deg, #1a8a7e 0%, #167a70 100%);
--btn-color: #e8fff9;
--btn-border: #1a8a7e;
--btn-shadow: rgba(26, 138, 126, 0.3);
--input-color: #e0ece9;
--input-placeholder: #7a9490;
--select-dropdown-color: #e0ece9;
--status-ready-color: #6ee6b7;
--status-failed-color: #f49b8f;
--status-processing-color: #f0c56d;
--status-pending-color: #9bb0b5;
}
}
/* Same overrides for JS-detected Streamlit dark theme */
html.dark-mode {
--ink: #e4eceb;
--ink-muted: #9bb0ae;
--card: #1e2d2d;
--card-border: #3a4e4b;
--accent: #3dd4c6;
--accent-soft: #1a3d39;
--warn-soft: #3d3223;
--warn-border: #b89040;
--ok-soft: #1a3d2e;
--ok-border: #3d9a6a;
--error-soft: #3d2220;
--error-border: #c25a4e;
--pending-soft: #2a3235;
--pending-border: #6b7b82;
--app-bg: linear-gradient(180deg, #141e1e 0%, #0f1817 100%);
--app-bg-overlay-1: radial-gradient(circle at 15% -5%, rgba(50, 40, 20, 0.4) 0%, rgba(50, 40, 20, 0) 42%);
--app-bg-overlay-2: radial-gradient(circle at 88% 8%, rgba(20, 60, 55, 0.3) 0%, rgba(20, 60, 55, 0) 44%);
--hero-bg: linear-gradient(125deg, rgba(20, 111, 103, 0.2) 0%, rgba(30, 45, 45, 0.95) 56%, rgba(25, 60, 55, 0.9) 100%);
--hero-border: #2a3e3c;
--hero-kicker-bg: rgba(30, 45, 45, 0.7);
--hero-kicker-color: #5ce0d3;
--hero-kicker-border: rgba(60, 180, 165, 0.3);
--soft-card-bg: rgba(30, 45, 45, 0.7);
--sidebar-bg: linear-gradient(180deg, #0e1f20 0%, #0a1617 100%);
--sidebar-text: #c8dad6;
--sidebar-input-bg: rgba(200, 218, 214, 0.08);
--sidebar-input-border: rgba(120, 170, 160, 0.25);
--sidebar-btn-bg: linear-gradient(135deg, #1a3332 0%, #162c2b 100%);
--sidebar-btn-color: #b0d8cf;
--sidebar-btn-border: #3a6e64;
--sidebar-btn-hover-bg: linear-gradient(135deg, #1f3d3b 0%, #1a3634 100%);
--btn-bg: linear-gradient(135deg, #1a8a7e 0%, #167a70 100%);
--btn-color: #e8fff9;
--btn-border: #1a8a7e;
--btn-shadow: rgba(26, 138, 126, 0.3);
--input-color: #e0ece9;
--input-placeholder: #7a9490;
--select-dropdown-color: #e0ece9;
--status-ready-color: #6ee6b7;
--status-failed-color: #f49b8f;
--status-processing-color: #f0c56d;
--status-pending-color: #9bb0b5;
}
.stApp {
background:
var(--app-bg-overlay-1),
var(--app-bg-overlay-2),
var(--app-bg);
color: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
}
.main .block-container {
max-width: 1180px;
padding-top: 1rem;
padding-bottom: 2rem;
}
p, li, label, [data-testid="stMarkdownContainer"] p {
line-height: 1.5;
color: var(--ink);
}
[data-testid="stSidebar"] {
background: var(--sidebar-bg);
}
[data-testid="stSidebar"] h1,
[data-testid="stSidebar"] h2,
[data-testid="stSidebar"] h3,
[data-testid="stSidebar"] h4,
[data-testid="stSidebar"] label,
[data-testid="stSidebar"] p,
[data-testid="stSidebar"] small,
[data-testid="stSidebar"] span {
color: var(--sidebar-text);
font-family: "IBM Plex Sans", sans-serif;
}
[data-testid="stSidebar"] .stTextInput input,
[data-testid="stSidebar"] .stTextArea textarea,
[data-testid="stSidebar"] div[data-baseweb="select"] > div,
[data-testid="stSidebar"] .stNumberInput input {
background: var(--sidebar-input-bg);
color: var(--sidebar-text);
border: 1px solid var(--sidebar-input-border);
}
[data-testid="stSidebar"] div[data-baseweb="select"] * {
color: var(--select-dropdown-color);
}
h1, h2, h3, h4 {
font-family: "Space Grotesk", sans-serif;
letter-spacing: 0.02em;
color: var(--ink);
}
.app-hero {
background: var(--hero-bg);
border: 1px solid var(--hero-border);
border-radius: 18px;
padding: 18px 22px;
margin-bottom: 14px;
box-shadow: 0 8px 24px rgba(17, 45, 42, 0.08);
}
.hero-kicker {
display: inline-block;
font-family: "Space Grotesk", sans-serif;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--hero-kicker-color);
background: var(--hero-kicker-bg);
border: 1px solid var(--hero-kicker-border);
border-radius: 999px;
padding: 3px 10px;
margin-bottom: 8px;
}
.hero-title {
margin: 0;
font-family: "Space Grotesk", sans-serif;
font-size: clamp(1.45rem, 2.4vw, 2rem);
line-height: 1.2;
color: var(--ink);
}
.hero-sub {
margin: 6px 0 0 0;
color: var(--ink-muted);
font-size: 0.95rem;
}
.soft-card {
background: var(--soft-card-bg);
border: 1px solid var(--card-border);
border-radius: 14px;
padding: 12px 14px;
color: var(--ink);
}
.metric-card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 14px;
padding: 10px 12px;
}
.metric-label {
color: var(--ink-muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.metric-value {
font-family: "Space Grotesk", sans-serif;
font-size: 1.4rem;
color: var(--ink);
margin-top: 2px;
}
.status-pill {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid transparent;
}
.status-ready {
background: var(--ok-soft);
border-color: var(--ok-border);
color: var(--status-ready-color);
}
.status-failed {
background: var(--error-soft);
border-color: var(--error-border);
color: var(--status-failed-color);
}
.status-processing {
background: var(--warn-soft);
border-color: var(--warn-border);
color: var(--status-processing-color);
}
.status-pending {
background: var(--pending-soft);
border-color: var(--pending-border);
color: var(--status-pending-color);
}
[data-testid="stText"] {
color: var(--ink-muted);
}
[role="radiogroup"] > label {
margin-right: 0.8rem;
padding: 0.2rem 0.1rem;
}
div.stButton > button,
div.stFormSubmitButton > button,
div.stDownloadButton > button {
border-radius: 11px;
border: 1px solid var(--btn-border);
background: var(--btn-bg);
color: var(--btn-color) !important;
font-weight: 600;
padding: 0.38rem 0.95rem;
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
box-shadow: 0 3px 10px var(--btn-shadow);
}
div.stButton > button *,
div.stFormSubmitButton > button *,
div.stDownloadButton > button * {
color: var(--btn-color) !important;
}
div.stButton > button:hover,
div.stFormSubmitButton > button:hover,
div.stDownloadButton > button:hover {
transform: translateY(-1px);
filter: brightness(1.03);
box-shadow: 0 5px 14px var(--btn-shadow);
}
div.stButton > button:focus,
div.stFormSubmitButton > button:focus,
div.stDownloadButton > button:focus {
outline: 2px solid #ffcb7f;
outline-offset: 2px;
}
[data-testid="stSidebar"] div.stButton > button,
[data-testid="stSidebar"] div.stFormSubmitButton > button,
[data-testid="stSidebar"] div.stDownloadButton > button {
background: var(--sidebar-btn-bg);
color: var(--sidebar-btn-color) !important;
border: 1px solid var(--sidebar-btn-border);
box-shadow: none;
}
[data-testid="stSidebar"] div.stButton > button *,
[data-testid="stSidebar"] div.stFormSubmitButton > button *,
[data-testid="stSidebar"] div.stDownloadButton > button * {
color: var(--sidebar-btn-color) !important;
}
[data-testid="stSidebar"] div.stButton > button:hover,
[data-testid="stSidebar"] div.stFormSubmitButton > button:hover,
[data-testid="stSidebar"] div.stDownloadButton > button:hover {
background: var(--sidebar-btn-hover-bg);
}
/* Streamlit base button variants (primary/secondary/tertiary) */
button[data-testid^="stBaseButton"] {
color: var(--ink) !important;
}
[data-testid="stSidebar"] button[data-testid^="stBaseButton"] {
color: var(--sidebar-btn-color) !important;
}
button[data-testid^="stBaseButton"] * {
color: inherit !important;
}
/* Keep form controls readable across light/dark */
.stTextInput input,
.stTextArea textarea,
.stNumberInput input,
div[data-baseweb="select"] > div {
color: var(--input-color) !important;
}
.stTextInput input::placeholder,
.stTextArea textarea::placeholder {
color: var(--input-placeholder) !important;
}
/* Chat message text */
[data-testid="stChatMessage"] p,
[data-testid="stChatMessage"] li,
[data-testid="stChatMessage"] span {
color: var(--ink) !important;
}
/* Expander text */
[data-testid="stExpander"] summary span {
color: var(--ink) !important;
}
/* Selectbox display text */
div[data-baseweb="select"] span {
color: var(--input-color) !important;
}
/* Tabs / segmented control labels */
[data-testid="stTabs"] button p {
color: var(--ink) !important;
}
/* Caption */
.stCaption, [data-testid="stCaptionContainer"] {
color: var(--ink-muted) !important;
}
[data-testid="stDataFrame"] {
border: 1px solid var(--card-border);
border-radius: 12px;
overflow: hidden;
}
</style>
<script>
// Detect Streamlit's internal dark theme by checking computed background color.
// Streamlit doesn't always use prefers-color-scheme, so we poll the actual bg.
(function() {
function checkDarkMode() {
var app = document.querySelector('[data-testid="stAppViewContainer"]')
|| document.querySelector('.stApp')
|| document.body;
var bg = window.getComputedStyle(app).backgroundColor;
var match = bg.match(/\d+/g);
if (match && match.length >= 3) {
var r = parseInt(match[0]), g = parseInt(match[1]), b = parseInt(match[2]);
var luminance = (r * 299 + g * 587 + b * 114) / 1000;
if (luminance < 128) {
document.documentElement.classList.add('dark-mode');
} else {
document.documentElement.classList.remove('dark-mode');
}
}
}
// Run immediately and then poll every 2 seconds (handles theme switches)
checkDarkMode();
setInterval(checkDarkMode, 2000);
})();
</script>
""",
unsafe_allow_html=True,
)
def status_key(value: str | None) -> str:
return str(value or "").strip().lower()
def render_status_pill(status: str | None, *, prefix: str = "") -> None:
key = status_key(status)
css = f"status-{key}" if key in STATUS_LABELS else "status-pending"
icon = STATUS_ICONS.get(key, "•")
label = STATUS_LABELS.get(key, str(status or "Unknown").title())
text = f"{prefix}{icon} {label}".strip()
st.markdown(f"<span class='status-pill {css}'>{text}</span>", unsafe_allow_html=True)
def render_metric_card(label: str, value: str | int) -> None:
st.markdown(
(
"<div class='metric-card'>"
f"<div class='metric-label'>{label}</div>"
f"<div class='metric-value'>{value}</div>"
"</div>"
),
unsafe_allow_html=True,
)
def build_artifact_option_label(artifact: dict[str, Any]) -> str:
art_id = artifact.get("id", "?")
art_type = str(artifact.get("type", "artifact")).title()
status = status_key(str(artifact.get("status", "")))
icon = STATUS_ICONS.get(status, "•")
label = STATUS_LABELS.get(status, str(artifact.get("status", "unknown")).title())
return f"#{art_id} · {art_type} · {icon} {label}"
def choose_workspace_section(notebook_id: int) -> str:
options = ["Source Library", "Chat Workspace", "Artifact Studio"]
key = f"workspace_section_{notebook_id}"
default_value = st.session_state.get(key, options[0])
if default_value not in options:
default_value = options[0]
if hasattr(st, "segmented_control"):
selected = st.segmented_control(
"Workspace sections",
options=options,
default=default_value,
key=key,
)
else:
selected = st.radio(
"Workspace sections",
options=options,
index=options.index(default_value),
key=key,
horizontal=True,
)
if selected not in options:
return options[0]
return str(selected)
def choose_artifact_for_notebook(notebook_id: int, artifacts: list[dict[str, Any]]) -> dict[str, Any] | None:
valid_artifacts = [
artifact for artifact in artifacts if isinstance(artifact, dict) and "id" in artifact
]
if not valid_artifacts:
return None
# Latest first for easier access to recently generated items.
valid_artifacts.sort(key=lambda a: int(a.get("id", 0)), reverse=True)
artifact_map = {int(a["id"]): a for a in valid_artifacts}
artifact_ids = list(artifact_map.keys())
state_key = f"selected_artifact_id_{notebook_id}"
previous_id = st.session_state.get(state_key)
if not isinstance(previous_id, int) or previous_id not in artifact_map:
previous_id = artifact_ids[0]
selected_id = st.selectbox(
"Select artifact",
options=artifact_ids,
index=artifact_ids.index(previous_id),
key=f"artifact_selector_{notebook_id}",
format_func=lambda aid: build_artifact_option_label(artifact_map[int(aid)]),
)
if not isinstance(selected_id, int):
return artifact_map[previous_id]
st.session_state[state_key] = selected_id
return artifact_map[selected_id]
inject_theme()
if "attempted_auto_dev_login" not in st.session_state:
st.session_state["attempted_auto_dev_login"] = False
if "bridge_error" not in st.session_state:
st.session_state["bridge_error"] = None
if "processed_bridge_token" not in st.session_state:
st.session_state["processed_bridge_token"] = None
if "processed_oauth_callback" not in st.session_state:
st.session_state["processed_oauth_callback"] = None
if "oauth_login_url" not in st.session_state:
st.session_state["oauth_login_url"] = None
bridge_token = get_query_param("auth_bridge")
if bridge_token and bridge_token != st.session_state["processed_bridge_token"]:
st.session_state["processed_bridge_token"] = bridge_token
bridge_ok, bridge_result, _ = api_post("/auth/bridge/exchange", {"token": bridge_token})
remove_query_param("auth_bridge")
if bridge_ok:
st.session_state["bridge_error"] = None
st.rerun()
st.session_state["bridge_error"] = str(bridge_result)
st.rerun()
oauth_error = get_query_param("error")
oauth_error_description = get_query_param("error_description")
oauth_code = get_query_param("code")
oauth_state = get_query_param("state")
if oauth_error:
st.session_state["bridge_error"] = (
f"OAuth provider error: {oauth_error} ({oauth_error_description})"
if oauth_error_description
else f"OAuth provider error: {oauth_error}"
)
for key in ("error", "error_description", "code", "state"):
remove_query_param(key)
st.rerun()
if oauth_code and oauth_state:
callback_key = f"{oauth_code}:{oauth_state}"
if callback_key != st.session_state["processed_oauth_callback"]:
st.session_state["processed_oauth_callback"] = callback_key
callback_ok, callback_error = complete_hf_oauth(code=oauth_code, state=oauth_state)
for key in ("code", "state", "auth_bridge"):
remove_query_param(key)
if callback_ok:
st.session_state["bridge_error"] = None
st.session_state["oauth_login_url"] = None
st.rerun()
st.session_state["bridge_error"] = str(callback_error)
st.rerun()
with st.sidebar:
st.markdown("### Workspace")
st.caption("Connected client settings")
st.text_input("Backend URL", value=BACKEND_URL, key="backend_url")
auth_ok, auth_result, _ = api_get("/auth/status")
auth_data = auth_result if (auth_ok and isinstance(auth_result, dict)) else {}
auth_mode = str(auth_data.get("mode", "unknown"))
authenticated = bool(auth_data.get("authenticated"))
auth_user = auth_data.get("user") if isinstance(auth_data.get("user"), dict) else None
if auth_mode == "dev" and not authenticated and not st.session_state["attempted_auto_dev_login"]:
st.session_state["attempted_auto_dev_login"] = True
login_ok, _, _ = api_post("/auth/dev-login", {})
if login_ok:
st.rerun()
st.subheader("Authentication")
st.caption(f"Mode: {auth_mode}")
bridge_error = st.session_state.get("bridge_error")
if bridge_error:
st.error(f"OAuth bridge failed: {bridge_error}")
st.session_state["bridge_error"] = None
if not auth_ok:
st.error(f"Failed to reach backend auth endpoint: {auth_result}")
elif authenticated and auth_user:
st.success(f"Signed in as {auth_user.get('email', 'unknown')}.")
if st.button("Sign out"):
api_post("/auth/logout", {})
st.session_state.pop("selected_notebook_id", None)
st.session_state.pop("selected_notebook_title", None)
st.session_state.pop("selected_thread_id", None)
st.session_state["oauth_login_url"] = None
st.rerun()
elif auth_mode == "dev":
with st.form("dev_login_form"):
email = st.text_input("Email", value="dev@example.com")
display_name = st.text_input("Display name", value="Dev User")
login_submitted = st.form_submit_button("Sign in")
if login_submitted:
login_ok, login_result, _ = api_post(
"/auth/dev-login",
{
"email": email.strip() or None,
"display_name": display_name.strip() or None,
},
)
if login_ok:
st.rerun()
st.error(f"Login failed: {login_result}")
elif auth_mode == "hf_oauth":
if not st.session_state.get("oauth_login_url"):
oauth_ok, oauth_result = begin_hf_oauth()
if oauth_ok:
st.session_state["oauth_login_url"] = oauth_result
else:
st.session_state["bridge_error"] = str(oauth_result)
login_url = st.session_state.get("oauth_login_url")
if login_url:
st.markdown(f"[Sign in with Hugging Face]({login_url})")
else:
st.warning("Unable to start OAuth. Check backend auth settings and retry.")
if st.button("Refresh login link"):
st.session_state["oauth_login_url"] = None
st.rerun()
st.caption("After provider login, you will be redirected back and signed in automatically.")
if st.button("Refresh auth"):
st.rerun()
else:
st.warning("Authentication is not configured.")
selected_notebook_id = st.session_state.get("selected_notebook_id")
selected_notebook_title = st.session_state.get("selected_notebook_title")
if selected_notebook_id:
st.caption(f"Current notebook: {selected_notebook_title} (ID: {selected_notebook_id})")
if st.button("Clear notebook selection"):
st.session_state.pop("selected_notebook_id", None)
st.session_state.pop("selected_notebook_title", None)
st.session_state.pop("selected_thread_id", None)
st.rerun()
hero_mode = auth_mode if isinstance(auth_mode, str) else "unknown"
hero_identity = "Not signed in"
if authenticated and isinstance(auth_user, dict):
hero_identity = str(auth_user.get("display_name") or auth_user.get("email") or "Signed in")
st.markdown(
(
"<div class='app-hero'>"
"<span class='hero-kicker'>Notebook Workspace</span>"
"<h1 class='hero-title'>NotebookLM Clone</h1>"
"<p class='hero-sub'>"
"Ingest sources, chat with citations, and generate reports, quizzes, and podcasts."
f" &nbsp;|&nbsp; Auth mode: <strong>{hero_mode}</strong>"
f" &nbsp;|&nbsp; User: <strong>{hero_identity}</strong>"
"</p>"
"</div>"
),
unsafe_allow_html=True,
)
if not auth_ok:
st.error(f"Backend auth check failed: {auth_result}")
st.stop()
if not authenticated:
st.info("Sign in to continue.")
st.stop()
page = "Notebooks"
if page == "Notebooks":
st.subheader("Notebooks")
st.markdown(
"<div class='soft-card'>Create or open a notebook, ingest sources, then use chat and artifact tools from one workspace.</div>",
unsafe_allow_html=True,
)
create_col, refresh_col = st.columns([3, 1])
with create_col:
with st.form("create_notebook_form"):
notebook_title = st.text_input("Notebook title", placeholder="e.g., AI Research Notes")
submitted = st.form_submit_button("Create notebook")
with refresh_col:
st.write("")
st.write("")
if st.button("Refresh notebooks", use_container_width=True):
st.rerun()
if submitted:
if notebook_title.strip():
payload = {
"title": notebook_title.strip(),
}
ok, result, _ = api_post("/notebooks", payload)
if ok:
st.success("Notebook created.")
st.json(result)
else:
st.error("Failed to create notebook.")
st.code(str(result))
else:
st.error("Notebook title is required.")
ok, result = fetch_notebooks()
if ok:
notebooks = result if isinstance(result, list) else []
selected_notebook_id = st.session_state.get("selected_notebook_id")
selected_notebook_title = st.session_state.get("selected_notebook_title")
metrics_left, metrics_mid, metrics_right = st.columns(3)
with metrics_left:
render_metric_card("Notebook Count", len(notebooks))
with metrics_mid:
render_metric_card("Selected Notebook", selected_notebook_id or "None")
with metrics_right:
render_metric_card("Workspace State", "Ready" if notebooks else "Empty")
if notebooks:
st.write("Your notebooks")
st.dataframe(notebooks, use_container_width=True, hide_index=True)
notebook_options = {
f"{n['id']} - {n['title']}": n
for n in notebooks
if isinstance(n, dict) and "id" in n and "title" in n
}
labels = list(notebook_options.keys())
default_index = 0
for idx, label in enumerate(labels):
if notebook_options[label]["id"] == selected_notebook_id:
default_index = idx
break
selected_label = st.selectbox("Select notebook to open", options=labels, index=default_index)
selected = notebook_options[selected_label]
if st.button("Open notebook"):
st.session_state["selected_notebook_id"] = selected["id"]
st.session_state["selected_notebook_title"] = selected["title"]
st.rerun()
selected_notebook_id = st.session_state.get("selected_notebook_id")
selected_notebook_title = st.session_state.get("selected_notebook_title")
if selected_notebook_id:
st.divider()
st.subheader(f"Notebook: {selected_notebook_title}")
render_status_pill("ready", prefix="Notebook ")
manage_left, manage_right = st.columns(2)
with manage_left:
with st.form("rename_notebook_form"):
renamed_title = st.text_input(
"Rename notebook",
value=selected_notebook_title or "",
key=f"rename_notebook_title_{selected_notebook_id}",
)
rename_submitted = st.form_submit_button("Save name")
if rename_submitted:
if not renamed_title.strip():
st.error("Notebook title cannot be empty.")
else:
ok, rename_result, _ = api_patch(
f"/notebooks/{selected_notebook_id}",
{"title": renamed_title.strip()},
)
if ok and isinstance(rename_result, dict):
st.success("Notebook renamed.")
st.session_state["selected_notebook_title"] = rename_result.get(
"title", renamed_title.strip()
)
st.rerun()
st.error("Failed to rename notebook.")
st.code(str(rename_result))
with manage_right:
with st.form("delete_notebook_form"):
confirm_delete = st.checkbox(
"I understand this permanently deletes this notebook and its data.",
value=False,
)
delete_submitted = st.form_submit_button("Delete notebook")
if delete_submitted:
if not confirm_delete:
st.error("Please confirm deletion first.")
else:
ok, delete_result, _ = api_delete(f"/notebooks/{selected_notebook_id}")
if ok:
st.success("Notebook deleted.")
st.session_state.pop("selected_notebook_id", None)
st.session_state.pop("selected_notebook_title", None)
st.session_state.pop("selected_thread_id", None)
st.rerun()
st.error("Failed to delete notebook.")
st.code(str(delete_result))
workspace_section = choose_workspace_section(int(selected_notebook_id))
st.caption("Switch sections to focus on one workflow at a time.")
if workspace_section == "Source Library":
st.markdown(
"<div class='soft-card'>Upload files or add links. Ingested sources are scoped to this notebook.</div>",
unsafe_allow_html=True,
)
ok, notebook_result = fetch_notebooks()
if not ok:
st.error("Failed to load notebooks.")
st.code(str(notebook_result))
else:
notebooks_for_user = notebook_result if isinstance(notebook_result, list) else []
notebook_ids = {
n["id"]
for n in notebooks_for_user
if isinstance(n, dict) and "id" in n
}
if selected_notebook_id not in notebook_ids:
st.error("Selected notebook is not available for this user.")
else:
with st.form("create_source_form"):
file_like_types = ["file", "pdf", "pptx", "txt", "md", "docx"]
source_type = st.selectbox(
"Source type", options=file_like_types + ["url", "text"], index=0
)
source_title = st.text_input("Source title (optional)")
original_name = st.text_input("Original filename (optional)")
source_url = st.text_input("Source URL (optional)")
storage_path = st.text_input("Storage path (optional)")
uploaded_file = st.file_uploader(
"Upload file (used for type=file)",
type=["pdf", "pptx", "txt", "md", "docx"],
)
source_status = st.selectbox(
"Status",
options=["pending", "processing", "ready", "failed"],
index=0,
)
source_submitted = st.form_submit_button("Add source")
if source_submitted:
resolved_original_name = original_name or None
resolved_title = source_title or None
if source_type in file_like_types:
if uploaded_file is None:
st.error("Please upload a file for this source type.")
st.stop()
resolved_original_name = resolved_original_name or uploaded_file.name
resolved_title = resolved_title or uploaded_file.name
form_data: dict[str, str | int] = {"status": source_status}
if resolved_title:
form_data["title"] = resolved_title
files_payload = {
"file": (
uploaded_file.name,
uploaded_file.getvalue(),
uploaded_file.type or "application/octet-stream",
)
}
ok, create_result, _ = api_post_multipart(
f"/notebooks/{selected_notebook_id}/sources/upload",
data=form_data,
files=files_payload,
)
if ok:
st.success("File uploaded and source added.")
st.json(create_result)
st.rerun()
else:
st.error("Failed to upload file source.")
st.code(str(create_result))
if source_type == "url" and not source_url.strip():
st.error("Please provide a URL when source type is 'url'.")
st.stop()
if source_type not in file_like_types:
payload = {
"type": source_type,
"title": resolved_title,
"original_name": resolved_original_name,
"url": source_url or None,
"storage_path": storage_path or None,
"status": source_status,
}
ok, create_result, _ = api_post(
f"/notebooks/{selected_notebook_id}/sources", payload
)
if ok:
st.success("Source added.")
st.json(create_result)
else:
st.error("Failed to add source.")
st.code(str(create_result))
if st.button("Refresh sources"):
st.rerun()
ok, source_result, _ = api_get(
f"/notebooks/{selected_notebook_id}/sources",
)
if ok:
sources = source_result if isinstance(source_result, list) else []
if sources:
ready_sources = sum(
1 for src in sources if status_key(str(src.get("status", ""))) == "ready"
)
processing_sources = sum(
1
for src in sources
if status_key(str(src.get("status", ""))) in {"processing", "pending"}
)
source_metrics_1, source_metrics_2, source_metrics_3 = st.columns(3)
with source_metrics_1:
render_metric_card("Source Count", len(sources))
with source_metrics_2:
render_metric_card("Ready Sources", ready_sources)
with source_metrics_3:
render_metric_card("In Flight", processing_sources)
st.write("Sources")
st.dataframe(sources, use_container_width=True, hide_index=True)
else:
st.info("No sources yet for this notebook.")
else:
st.error("Failed to fetch sources.")
st.code(str(source_result))
elif workspace_section == "Chat Workspace":
st.markdown(
"<div class='soft-card'>Ask questions grounded in your selected notebook sources. Citations are shown per assistant response.</div>",
unsafe_allow_html=True,
)
st.write("Chat threads")
ok, thread_result, _ = api_get(
f"/notebooks/{selected_notebook_id}/threads",
)
threads = thread_result if (ok and isinstance(thread_result, list)) else []
render_metric_card("Thread Count", len(threads))
with st.form("create_thread_form"):
thread_title = st.text_input("Thread title (optional)")
create_thread_submitted = st.form_submit_button("Create thread")
if create_thread_submitted:
payload = {
"title": thread_title.strip() or None,
}
ok, create_thread_result, _ = api_post(
f"/notebooks/{selected_notebook_id}/threads", payload
)
if ok and isinstance(create_thread_result, dict):
st.success("Thread created.")
st.session_state["selected_thread_id"] = create_thread_result["id"]
st.rerun()
else:
st.error("Failed to create thread.")
st.code(str(create_thread_result))
if threads:
thread_options = {
f"{t['id']} - {t.get('title') or 'Untitled'}": t["id"] for t in threads
}
thread_labels = list(thread_options.keys())
selected_thread_id = st.session_state.get("selected_thread_id")
default_thread_index = 0
if selected_thread_id:
for idx, label in enumerate(thread_labels):
if thread_options[label] == selected_thread_id:
default_thread_index = idx
break
selected_thread_label = st.selectbox(
"Select thread",
options=thread_labels,
index=default_thread_index,
key="selected_thread_label",
)
selected_thread_id = thread_options[selected_thread_label]
st.session_state["selected_thread_id"] = selected_thread_id
ok, message_result, _ = api_get(
f"/threads/{selected_thread_id}/messages",
params={"notebook_id": selected_notebook_id},
)
if ok and isinstance(message_result, list):
st.write("Messages")
for msg in message_result:
role = msg.get("role", "unknown")
content = msg.get("content", "")
citations = msg.get("citations", [])
message_type = "assistant" if role == "assistant" else "user"
with st.chat_message(message_type):
st.markdown(str(content))
if role == "assistant" and isinstance(citations, list) and citations:
with st.expander("Citations", expanded=False):
st.dataframe(citations, use_container_width=True, hide_index=True)
else:
st.error("Failed to fetch messages.")
st.code(str(message_result))
with st.form("chat_form", clear_on_submit=True):
question = st.text_input("Ask a question")
ask_submitted = st.form_submit_button("Send")
if ask_submitted:
if not question.strip():
st.error("Question cannot be empty.")
else:
payload = {
"question": question.strip(),
"top_k": 5,
}
ok, chat_result, _ = api_post(
f"/threads/{selected_thread_id}/chat?notebook_id={selected_notebook_id}",
payload,
)
if ok and isinstance(chat_result, dict):
st.success("Response generated.")
citations = chat_result.get("citations", [])
if citations:
st.write("Citations")
st.dataframe(citations, use_container_width=True)
st.rerun()
else:
st.error("Chat request failed.")
st.code(str(chat_result))
else:
st.info("Create a thread to start chatting.")
else:
st.markdown(
"<div class='soft-card'>Generate structured outputs from notebook context and track progress here.</div>",
unsafe_allow_html=True,
)
report_col, quiz_col, podcast_col = st.columns(3)
with report_col:
with st.form("generate_report_form"):
report_title = st.text_input(
"Report title (optional)",
key=f"report_title_{selected_notebook_id}",
)
report_detail = st.selectbox(
"Detail level",
options=["short", "medium", "long"],
index=1,
key=f"report_detail_{selected_notebook_id}",
)
report_topic = st.text_input(
"Topic focus (optional)",
key=f"report_topic_{selected_notebook_id}",
)
report_submitted = st.form_submit_button("Generate report")
if report_submitted:
payload = {
"title": report_title.strip() or None,
"detail_level": report_detail,
"topic_focus": report_topic.strip() or None,
}
ok, report_result, _ = api_post(
f"/notebooks/{selected_notebook_id}/artifacts/report",
payload,
)
if ok:
st.success("Report generated.")
st.rerun()
else:
st.error("Report generation failed.")
st.code(str(report_result))
with quiz_col:
with st.form("generate_quiz_form"):
quiz_title = st.text_input(
"Quiz title (optional)",
key=f"quiz_title_{selected_notebook_id}",
)
quiz_questions = st.number_input(
"Questions",
min_value=1,
max_value=20,
value=5,
step=1,
key=f"quiz_questions_{selected_notebook_id}",
)
quiz_difficulty = st.selectbox(
"Difficulty",
options=["easy", "medium", "hard"],
index=1,
key=f"quiz_difficulty_{selected_notebook_id}",
)
quiz_topic = st.text_input(
"Topic focus (optional)",
key=f"quiz_topic_{selected_notebook_id}",
)
quiz_submitted = st.form_submit_button("Generate quiz")
if quiz_submitted:
payload = {
"title": quiz_title.strip() or None,
"num_questions": int(quiz_questions),
"difficulty": quiz_difficulty,
"topic_focus": quiz_topic.strip() or None,
}
ok, quiz_result, _ = api_post(
f"/notebooks/{selected_notebook_id}/artifacts/quiz",
payload,
)
if ok:
st.success("Quiz generated.")
st.rerun()
else:
st.error("Quiz generation failed.")
st.code(str(quiz_result))
with podcast_col:
with st.form("generate_podcast_form"):
podcast_title = st.text_input(
"Podcast title (optional)",
key=f"podcast_title_{selected_notebook_id}",
)
podcast_duration = st.selectbox(
"Duration",
options=["5min", "10min", "15min", "20min"],
index=0,
key=f"podcast_duration_{selected_notebook_id}",
)
podcast_topic = st.text_input(
"Topic focus (optional)",
key=f"podcast_topic_{selected_notebook_id}",
)
podcast_submitted = st.form_submit_button("Generate podcast")
if podcast_submitted:
payload = {
"title": podcast_title.strip() or None,
"duration": podcast_duration,
"topic_focus": podcast_topic.strip() or None,
}
ok, podcast_result, _ = api_post(
f"/notebooks/{selected_notebook_id}/artifacts/podcast",
payload,
)
if ok:
st.success("Podcast generation started.")
st.rerun()
else:
st.error("Podcast generation failed.")
st.code(str(podcast_result))
st.divider()
if st.button("Refresh artifacts"):
st.rerun()
ok, artifact_result, _ = api_get(f"/notebooks/{selected_notebook_id}/artifacts")
if ok and isinstance(artifact_result, list):
artifacts = artifact_result
if artifacts:
auto_refresh_key = f"auto_refresh_artifacts_{selected_notebook_id}"
auto_refresh = st.checkbox(
"Auto-refresh while artifacts are processing",
value=bool(st.session_state.get(auto_refresh_key, True)),
key=auto_refresh_key,
)
ready_count = sum(
1 for artifact in artifacts if status_key(str(artifact.get("status", ""))) == "ready"
)
failed_count = sum(
1 for artifact in artifacts if status_key(str(artifact.get("status", ""))) == "failed"
)
in_flight = sum(
1
for artifact in artifacts
if status_key(str(artifact.get("status", ""))) in {"pending", "processing"}
)
artifact_metric_1, artifact_metric_2, artifact_metric_3, artifact_metric_4 = st.columns(4)
with artifact_metric_1:
render_metric_card("Artifacts", len(artifacts))
with artifact_metric_2:
render_metric_card("Ready", ready_count)
with artifact_metric_3:
render_metric_card("Processing", in_flight)
with artifact_metric_4:
render_metric_card("Failed", failed_count)
st.dataframe(artifacts, use_container_width=True, hide_index=True)
selected_artifact = choose_artifact_for_notebook(
int(selected_notebook_id),
artifacts,
)
if selected_artifact is None:
st.info("Select an artifact to preview.")
else:
artifact_id = int(selected_artifact["id"])
artifact_type = str(selected_artifact.get("type", ""))
artifact_status = str(selected_artifact.get("status", ""))
artifact_content = selected_artifact.get("content")
artifact_error = selected_artifact.get("error_message")
status_col, type_col = st.columns([1, 4])
with status_col:
render_status_pill(artifact_status)
with type_col:
st.caption(f"Artifact type: {artifact_type}")
if artifact_error:
st.error(str(artifact_error))
if artifact_type == "report" and artifact_content:
st.markdown("### Report Preview")
st.markdown(str(artifact_content))
st.download_button(
"Download report (.md)",
data=str(artifact_content).encode("utf-8"),
file_name=f"report_{artifact_id}.md",
mime="text/markdown",
)
elif artifact_type == "quiz" and artifact_content:
st.markdown("### Quiz Preview")
st.markdown(str(artifact_content))
st.download_button(
"Download quiz (.md)",
data=str(artifact_content).encode("utf-8"),
file_name=f"quiz_{artifact_id}.md",
mime="text/markdown",
)
elif artifact_type == "podcast":
if artifact_content:
st.markdown("### Transcript")
st.markdown(str(artifact_content))
st.download_button(
"Download transcript (.md)",
data=str(artifact_content).encode("utf-8"),
file_name=f"podcast_transcript_{artifact_id}.md",
mime="text/markdown",
)
if artifact_status == "ready":
ok_audio, audio_result, _ = api_get_bytes(
f"/notebooks/{selected_notebook_id}/artifacts/{artifact_id}/audio"
)
if ok_audio and isinstance(audio_result, bytes):
st.audio(audio_result, format="audio/mp3")
st.download_button(
"Download podcast (.mp3)",
data=audio_result,
file_name=f"podcast_{artifact_id}.mp3",
mime="audio/mpeg",
)
else:
st.error(f"Unable to load audio: {audio_result}")
else:
st.info(f"Podcast status: {artifact_status}")
else:
st.info("Select an artifact to preview.")
if auto_refresh and in_flight > 0:
st.caption(
f"{in_flight} artifact(s) still processing. "
"Refreshing in 4 seconds..."
)
time.sleep(4)
st.rerun()
else:
st.info("No artifacts generated yet.")
else:
st.error("Failed to fetch artifacts.")
st.code(str(artifact_result))
else:
st.info("No notebooks yet.")
else:
st.error("Failed to fetch notebooks.")
st.code(str(result))