Grounded Ask owns the product chat UI: persistent thread_id across a Chat Session, New chat / Continue conversation, and a sidebar that lists Conversations from the Conversation Record with formatted run metadata (timing, tokens, evidence chunks). Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""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(
|
|
'<div class="empty-card"><h3>First-time setup</h3>'
|
|
"<p>1. Copy an API key that can upload files and run the agent "
|
|
"(<code>files:write</code>, <code>domains:*</code>, <code>threads:run</code>).<br/>"
|
|
"2. Paste it below and click <strong>Save & continue</strong>.</p></div>",
|
|
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'<div class="status-ok">Connected as <strong>{_escape(active.label)}</strong> '
|
|
f"→ <code>{_escape(active.base_url)}</code></div>",
|
|
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()
|