"""Streamlit UI: chat streams; audits stream in background; button reveals.""" from __future__ import annotations import uuid from datetime import timedelta import streamlit as st from agent.background import ( schedule_audit, schedule_missing_audits, sync_audits, ) from agent.chat import chat_reply_stream from agent.state import ChatTurn, MessageAudit from config import ( AUDIT_MODEL, AUDIT_USE_WEB_SEARCH, CHAT_MODEL, SEARCH_MODEL, get_api_key, ) from ui.render import conversation_table_html APP_TITLE = "Anti-Hallucination Chat" _COMPOSER_CSS = """ """ def _ensure_state() -> None: if "turns" not in st.session_state: st.session_state.turns: list[ChatTurn] = [] if "audits_by_id" not in st.session_state: st.session_state.audits_by_id: dict[str, MessageAudit] = {} if "show_audit_view" not in st.session_state: st.session_state.show_audit_view = False if "audit_session_id" not in st.session_state: st.session_state.audit_session_id = str(uuid.uuid4()) if "streaming_now" not in st.session_state: st.session_state.streaming_now = False if "partial_assistant" not in st.session_state: st.session_state.partial_assistant = "" def _sync_background() -> bool: return sync_audits( st.session_state.audit_session_id, st.session_state.audits_by_id, ) def _audits_active() -> bool: return any( audit.get("status") in ("running", "pending") for audit in st.session_state.audits_by_id.values() ) def _render_turn_table(*, streaming_assistant: str | None = None) -> None: st.markdown( conversation_table_html( st.session_state.turns, st.session_state.audits_by_id, show_audit=bool(st.session_state.show_audit_view), streaming_assistant=streaming_assistant, ), unsafe_allow_html=True, ) def _toggle_audit_view() -> None: """Toggle columns 2–3 (claims / reasoning) on or off.""" opening = not st.session_state.show_audit_view if opening: schedule_missing_audits( st.session_state.audit_session_id, st.session_state.turns, ) _sync_background() st.session_state.show_audit_view = opening st.rerun() def _check_button_label() -> str: if st.session_state.show_audit_view: if _audits_active(): return "Hide audit (streaming…)" return "Hide audit" if _audits_active(): return "Check (ready)" return "Hallucination check" def _finalize_partial_assistant() -> None: """If a prior run was interrupted mid-stream, keep whatever text we got.""" if not st.session_state.get("streaming_now"): return partial = (st.session_state.get("partial_assistant") or "").strip() st.session_state.streaming_now = False st.session_state.partial_assistant = "" if not partial: return if st.session_state.turns and st.session_state.turns[-1]["role"] == "assistant": return assistant_turn: ChatTurn = { "id": str(uuid.uuid4()), "role": "assistant", "content": partial, } st.session_state.turns.append(assistant_turn) schedule_audit( st.session_state.audit_session_id, assistant_turn, list(st.session_state.turns), ) def _render_composer() -> str | None: """Message input + check toggle, pinned to the viewport bottom. While a new prompt is being submitted (this run will stream), the check button is omitted so it cannot interrupt the assistant reply. """ st.markdown(_COMPOSER_CSS, unsafe_allow_html=True) with st.bottom: input_col, check_col = st.columns([5.2, 1.55], vertical_alignment="center") with input_col: prompt = st.chat_input("Message…", key="ah_chat_input") with check_col: if prompt: # Hide check for the streaming run — keeps layout slot empty. st.empty() elif st.button( _check_button_label(), type="primary", key="ah_hallucination_check", use_container_width=True, ): _toggle_audit_view() return prompt @st.fragment(run_every=timedelta(seconds=1)) def _audit_refresh_fragment() -> None: """Always-mounted fragment so run_every never targets a missing id. Polls background audit snapshots and streams bullets into the reveal panel. """ active = _sync_background() if not st.session_state.show_audit_view: return if st.session_state.get("streaming_now"): return _render_turn_table() if active or _audits_active(): st.caption("⏳ Audit stream updating…") def _stream_assistant_reply() -> str | None: """Stream the next assistant reply into the table (no chat-bubble UI).""" table_slot = st.empty() err_slot = st.empty() chunks: list[str] = [] def _render_partial(text: str) -> None: st.session_state.partial_assistant = text table_slot.markdown( conversation_table_html( st.session_state.turns, st.session_state.audits_by_id, show_audit=bool(st.session_state.show_audit_view), streaming_assistant=text or "…", ), unsafe_allow_html=True, ) _render_partial("") try: for piece in chat_reply_stream(list(st.session_state.turns)): chunks.append(str(piece)) _render_partial("".join(chunks)) except Exception as exc: # noqa: BLE001 err_slot.error(f"Chat failed: {exc}") return None text = "".join(chunks).strip() if not text: err_slot.error("Chat failed: empty model response") return None return text def run_chat() -> None: st.set_page_config( page_title=APP_TITLE, page_icon="💬", layout="wide", ) st.title(APP_TITLE) search_note = ( f"search {SEARCH_MODEL}" if AUDIT_USE_WEB_SEARCH else "web search off" ) st.caption( f"v5 · chat {CHAT_MODEL} · audit {AUDIT_MODEL} · {search_note}" ) if not get_api_key(): st.error( "GOOGLE_API_KEY is missing. " "Set it in a local `.env` file or as a Hugging Face Space secret." ) st.stop() _ensure_state() _sync_background() # If a previous run was cut off mid-stream, keep any partial assistant text. _finalize_partial_assistant() prompt = _render_composer() if prompt: st.session_state.streaming_now = True st.session_state.partial_assistant = "" # Keep this fragment mounted on every run — conditional fragments break the # Cursor Cloud preview when run_every targets a removed fragment id. _audit_refresh_fragment() if prompt: user_turn: ChatTurn = { "id": str(uuid.uuid4()), "role": "user", "content": prompt, } st.session_state.turns.append(user_turn) schedule_audit( st.session_state.audit_session_id, user_turn, list(st.session_state.turns), ) text = _stream_assistant_reply() st.session_state.streaming_now = False st.session_state.partial_assistant = "" if text is None: st.session_state.turns.pop() return assistant_turn: ChatTurn = { "id": str(uuid.uuid4()), "role": "assistant", "content": text, } st.session_state.turns.append(assistant_turn) schedule_audit( st.session_state.audit_session_id, assistant_turn, list(st.session_state.turns), ) st.rerun() return if not st.session_state.show_audit_view: _render_turn_table()