"""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()