"""Grounded Ask — single Ask Page pipeline."""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import streamlit as st
from dotenv import load_dotenv
from src.api_client import ApiClient
from src.chat_view import conversation_button_label, render_transcript, render_turn_meta
from src.profiles import (
delete_profile,
get_profile,
list_profiles,
upsert_profile,
)
from src.theme import hero, inject, section
load_dotenv()
def _escape(text: str) -> str:
return (
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
st.set_page_config(
page_title="Grounded Ask",
page_icon="▣",
layout="wide",
initial_sidebar_state="expanded",
)
inject()
hero()
# One thread_id per Chat Session (ADR-0025): held here until "New chat" is
# clicked, or an old Conversation is reactivated via "Continue this
# conversation" — never minted fresh per question.
st.session_state.setdefault("active_thread_id", None)
st.session_state.setdefault("chat_log", [])
st.session_state.setdefault("viewing_thread_id", None)
# --- Connect -----------------------------------------------------------------
profiles = list_profiles()
labels = [f"{p.label}" for p in profiles]
id_by_label = {p.label: p.id for p in profiles}
if not profiles:
section("Connect", "You need an API key from the chatbot API (same key as Hybrid Console).")
st.markdown(
'
First-time setup
'
"
1. Copy an API key that can upload files and run the agent "
"(files:write, domains:*, threads:run).
"
"2. Paste it below and click Save & continue.
",
unsafe_allow_html=True,
)
default_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
label = st.text_input("Name for this connection", value="Local", key="pf_label")
base_url = st.text_input("API address", value=default_url, key="pf_url")
api_key = st.text_input(
"API key",
type="password",
key="pf_key",
placeholder="sk_…",
help="Paste the full key once. It stays on this computer only.",
)
if st.button(
"Save & continue",
type="primary",
disabled=not (label.strip() and api_key.strip()),
):
saved = upsert_profile(label=label, base_url=base_url, api_key=api_key)
st.session_state["profile_choice"] = saved.label
st.rerun()
st.stop()
section("Connect", "Which API account should this page use?")
choice = st.selectbox(
"Saved connection",
options=labels,
key="profile_choice",
label_visibility="collapsed",
)
active = get_profile(id_by_label[choice])
assert active is not None
st.markdown(
f'Connected as {_escape(active.label)} '
f"→ {_escape(active.base_url)}
",
unsafe_allow_html=True,
)
with st.expander("Change connection or add another"):
default_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
label = st.text_input("Name", value=active.label, key="pf_label")
base_url = st.text_input("API address", value=active.base_url or default_url, key="pf_url")
api_key = st.text_input(
"API key",
type="password",
key="pf_key",
placeholder="Leave blank to keep the current key",
)
c1, c2 = st.columns(2)
with c1:
if st.button("Save connection", type="primary"):
key = api_key.strip() or active.api_key
if not key:
st.error("API key is required.")
else:
saved = upsert_profile(
label=label,
base_url=base_url,
api_key=key,
profile_id=active.id,
)
st.session_state["profile_choice"] = saved.label
st.success("Saved")
st.rerun()
with c2:
if st.button("Remove this connection"):
delete_profile(active.id)
st.rerun()
client = ApiClient(active)
# --- Sidebar: New chat + Conversation history (ADR-0025) ---------------------
with st.sidebar:
st.markdown("### Conversations")
if st.button("+ New chat", type="primary", use_container_width=True):
st.session_state["active_thread_id"] = None
st.session_state["chat_log"] = []
st.session_state["viewing_thread_id"] = None
st.rerun()
st.divider()
conv_list = client.list_conversations(limit=30)
if conv_list.error:
st.caption(f"History unavailable ({conv_list.error}).")
elif not conv_list.ok:
st.caption(f"History unavailable (HTTP {conv_list.status_code}).")
else:
items: list[dict] = []
if isinstance(conv_list.body_json, dict):
items = conv_list.body_json.get("items") or []
if not items:
st.caption("No conversations yet — ask something to start one.")
for item in items:
thread_id = str(item.get("thread_id", ""))
is_active = thread_id == st.session_state.get("active_thread_id")
label = ("● " if is_active else "") + conversation_button_label(item)
if st.button(label, key=f"conv_{thread_id}", use_container_width=True):
st.session_state["viewing_thread_id"] = thread_id
st.rerun()
# --- Add documents -----------------------------------------------------------
section(
"Add documents",
"Put files into a topic folder (called a domain). Skip this if you already uploaded.",
)
domains_result = client.list_domains(include_disabled=False)
domain_names: list[str] = []
if domains_result.ok and isinstance(domains_result.body_json, dict):
domain_names = [
str(item.get("domain"))
for item in domains_result.body_json.get("domains", [])
if isinstance(item, dict) and item.get("domain")
]
elif domains_result.error or (domains_result.status_code and domains_result.status_code >= 400):
st.error(
"Could not reach the API or list topics. "
f"({domains_result.status_code or domains_result.error}). "
"Check that the API is running and this key has domains:read."
)
with st.expander("Upload a document", expanded=not domain_names):
domain_mode = st.radio(
"Topic folder",
options=["Use existing", "Create new"],
horizontal=True,
key="domain_mode",
)
if domain_mode == "Use existing":
domain = st.selectbox(
"Which topic?",
options=domain_names or ["(none yet — create one)"],
disabled=not domain_names,
key="upload_domain",
)
if not domain_names:
domain = ""
else:
domain = st.text_input(
"New topic id",
placeholder="fire",
help="Short id: lowercase letters, digits, _ or - (example: fire, life)",
key="new_domain",
)
display_name = st.text_input(
"Display name",
value="",
placeholder="Fire insurance",
key="new_domain_display",
)
if st.button("Create topic", disabled=not domain.strip()):
created = client.create_domain(
domain=domain.strip(),
display_name=(display_name or domain).strip(),
)
if created.ok:
st.success(f"Topic `{domain.strip()}` is ready — upload a file next.")
st.rerun()
else:
st.error(created.error or created.body_text or f"HTTP {created.status_code}")
uploaded = st.file_uploader(
"Choose a file",
type=["csv", "xlsx", "docx", "doc"],
key="upload_file",
help="CSV, Excel, or Word.",
)
can_upload = bool(domain and domain not in ("(none yet — create one)",) and uploaded is not None)
if st.button("Upload & index", type="primary", disabled=not can_upload):
assert uploaded is not None
with st.spinner("Uploading and indexing…"):
result = client.upload_file(
domain=str(domain).strip(),
filename=uploaded.name,
content=uploaded.getvalue(),
)
if result.ok:
body = result.body_json if isinstance(result.body_json, dict) else {}
st.success(
f"Done — {body.get('chunks_indexed', '?')} chunks indexed from "
f"**{uploaded.name}**."
)
else:
st.error(result.error or result.body_text or f"HTTP {result.status_code}")
if domain_names:
st.caption("Topics available: " + ", ".join(f"`{d}`" for d in domain_names))
# --- Chat (ADR-0025: multi-turn, plus this Conversation's own history) ------
section(
"Chat",
"Searches the whole tenant corpus. Remembers this conversation until you click New chat.",
)
viewing_thread_id = st.session_state.get("viewing_thread_id")
if viewing_thread_id and viewing_thread_id != st.session_state.get("active_thread_id"):
# Read-only: browsing a past Conversation opened from the sidebar.
detail = client.list_recorded_runs(viewing_thread_id)
if detail.error or not detail.ok:
st.error(detail.error or detail.body_text or f"HTTP {detail.status_code}")
else:
runs = detail.body_json.get("runs", []) if isinstance(detail.body_json, dict) else []
st.caption(f"Viewing a past conversation · `{viewing_thread_id}`")
render_transcript(runs)
if st.button("Continue this conversation", type="primary"):
chat_log: list[dict] = []
for run in runs:
chat_log.append({"role": "user", "content": run.get("user_message") or ""})
chat_log.append(
{
"role": "assistant",
"content": run.get("assistant_message") or "",
"meta": run,
}
)
st.session_state["active_thread_id"] = viewing_thread_id
st.session_state["chat_log"] = chat_log
st.session_state["viewing_thread_id"] = None
st.rerun()
else:
# Live chat on the active Chat Session's thread_id.
for msg in st.session_state["chat_log"]:
with st.chat_message(msg["role"]):
st.write(msg["content"] or ("—" if msg["role"] == "assistant" else ""))
if msg.get("meta"):
render_turn_meta(msg["meta"])
if not st.session_state["chat_log"]:
st.caption("Ask anything about the documents you've uploaded.")
question = st.chat_input("Ask a question…")
if question:
if st.session_state.get("active_thread_id") is None:
st.session_state["active_thread_id"] = str(uuid.uuid4())
thread_id = st.session_state["active_thread_id"]
user_id = "grounded-ask-operator"
st.session_state["chat_log"].append({"role": "user", "content": question})
with st.chat_message("user"):
st.write(question)
with st.chat_message("assistant"):
placeholder = st.empty()
accumulated: list[str] = []
def on_token(text: str) -> None:
accumulated.append(text)
placeholder.write("".join(accumulated))
with st.spinner("Thinking…"):
run = client.stream_run(
thread_id, message=question, user_id=user_id, on_token=on_token
)
meta: dict | None = None
if run.stream_error:
st.error(f"{run.stream_error.get('code')}: {run.stream_error.get('message')}")
content = "".join(accumulated)
elif run.error and not run.stream_result:
st.error(run.error)
content = "".join(accumulated)
else:
content = (run.stream_result or {}).get("message") or "".join(accumulated)
# A pure escalation turn can have empty prose (the model went
# straight to the `escalate` tool call) -- match the historical
# transcript's fallback so a live turn doesn't render as a blank
# bubble.
placeholder.write(content or "—")
# The Conversation Record write happens inside stream_run's own
# `finally`, before the SSE stream closes (ADR-0024) — so by the
# time this call returns, the just-finished Run's full metadata
# (tokens, timing, chunks) is already archived and readable.
runs_after = client.list_recorded_runs(thread_id)
if runs_after.ok and isinstance(runs_after.body_json, dict):
all_runs = runs_after.body_json.get("runs") or []
if all_runs:
meta = all_runs[-1]
if meta:
render_turn_meta(meta)
st.session_state["chat_log"].append(
{"role": "assistant", "content": content, "meta": meta}
)
st.rerun()