Source code for scripts.ai_assistant.web_ui

"""RePORT AI Portal Chat UI — entry point.

Launch:
    uv run streamlit run scripts/ai_assistant/web_ui.py
    or
    uv run python main.py --web
"""

from __future__ import annotations

import logging
import shutil
import uuid
from datetime import UTC, datetime
from datetime import timedelta as _timedelta
from pathlib import Path
from typing import Any

import streamlit as st

import config
from scripts.ai_assistant.ui import chat, shell, wizard
from scripts.ai_assistant.ui.auth import enforce_auth_boundary
from scripts.ai_assistant.ui.conversations import (
    _conversation_has_artifacts,
    _conversations_dir,
    _export_conversation_as_md,
    _export_conversation_as_text,
    _export_plots_as_zip,
    _export_tables_as_zip,
    _list_conversations,
    _load_conversation,
    _relative_time,
    _save_conversation,
    _search_conversations,
)
from scripts.ai_assistant.ui.providers import (
    _OTHER_MODEL_OPTION,
    _PROVIDER_CONFIG,
    _default_provider_label,
)
from scripts.ai_assistant.ui.state import init_state

_CSS_PATH = Path(__file__).parent / "ui" / "assets" / "theme.css"

logger = logging.getLogger(__name__)


def _inject_redesign_css() -> None:
    """Hydrate the body class + per-user appearance attributes.

    The CSS rules that used to live in ``theme_redesign.css`` are now appended
    at the end of ``theme.css`` (scoped to ``body.rpln-redesign``) so one file
    serves the whole app. This function is still called post-chat-start to
    (1) flip the body class from ``rpln-wizard`` to ``rpln-redesign`` and
    (2) rehydrate the five data-* appearance attributes the CSS variants key
    on (theme, bubble, aprose, density, accent).
    """
    with st.container(key="rpln_ui_bridge_redesign"):
        st.iframe(
            "<!doctype html><html><body style='margin:0;overflow:hidden'>"
            "<script>try{"
            "var pb=window.parent.document.body;"
            "pb.classList.remove('rpln-wizard');"
            "pb.classList.add('rpln-redesign');"
            "var d={theme:'terracotta',bubble:'tail',aprose:'serif',"
            "density:'normal',accent:'C96442'};"
            "var raw=localStorage.getItem('report_ai_portal_appearance_v1');"
            "var a={};if(raw){try{a=JSON.parse(raw)||{};}catch(e){a={};}}"
            "['theme','bubble','aprose','density','accent'].forEach(function(k){"
            "pb.setAttribute('data-'+k,a[k]||d[k]);"
            "});"
            "}catch(e){}</script></body></html>",
            width="content",
            height="content",
            tab_index=-1,
        )
    # WP-F.05.08 — mobile scrim (covers chat canvas when sidebar is drawn
    # as a drawer on <=840 px viewports). Toggled by bridge.js.
    st.html('<div class="rpln-mobile-scrim" data-rpln-mobile-scrim></div>')


[docs] def main() -> None: """Streamlit app entry point.""" st.set_page_config( page_title=f"RePORT AI Portal — {config.STUDY_NAME}", page_icon="🔬", layout="wide", initial_sidebar_state="expanded", ) enforce_auth_boundary() init_state() shell.inject_css() shell.install_bridge() # Extra view-state keys not in init_state (UI-only ephemeral flags) for _key, _val in [ ("rpln_view", "chat"), ("rpln_theme", "dark"), ("rpln_clear_confirm", False), ("rpln_search_modal_query", ""), ("adaptive_thinking", False), ("rpln_adaptive_toggle", False), ("rpln_adaptive_default_applied", False), ("rpln_model_more_open", False), # WP-F.05.01 — redesign gate. Default True so returning users (who # already have setup_complete=True persisted) see the new UI too. ("chat_started", True), ]: if _key not in st.session_state: st.session_state[_key] = _val if not st.session_state.get("rpln_adaptive_default_applied", False): st.session_state.adaptive_thinking = False st.session_state.rpln_adaptive_toggle = False st.session_state.rpln_adaptive_default_applied = True # WP-F.05.01 — layer redesign tokens + body class after wizard completes. # Wizard path (body.rpln-wizard) stays on legacy theme.css verbatim. if st.session_state.get("setup_complete") and st.session_state.get("chat_started"): _inject_redesign_css() # Handle end-chat shutdown if st.session_state.get("ended") or st.query_params.get("shutdown") == "1": _goodbye() return # Always re-sync LLM config from session state after hot-reload if st.session_state.setup_complete: wizard.ensure_llm_config() # Setup wizard until LLM + study data are configured if not st.session_state.setup_complete: wizard.render_setup_page() return # Search modal (st.dialog — must open before sidebar render) if st.session_state.get("rpln_search_modal_open"): st.session_state.rpln_search_modal_open = False _render_search_modal() # Settings panel replaces chat canvas; sidebar still visible if st.session_state.get("rpln_view") == "settings": shell.sidebar() _render_settings_panel() return # Main chat layout shell.sidebar() shell.topbar() with st.container(key="rpln_thread_shell"): assistant_slot = chat.render_thread() chat.composer(assistant_slot=assistant_slot)
# --------------------------------------------------------------------------- # Goodbye page # --------------------------------------------------------------------------- def _goodbye() -> None: st.html( """ <div class="rpln-goodbye"> <div class="rpln-goodbye-mark">R</div> <h1>Thank you for using RePORT AI Portal</h1> <p>Your session has ended. Close this tab to return to the workspace.</p> </div> """ ) st.stop() # --------------------------------------------------------------------------- # Search modal # --------------------------------------------------------------------------- @st.dialog("Search chats") def _render_search_modal() -> None: """Centered search modal: live-filter conversations grouped by recency.""" query_key = "rpln_search_modal_query" clear_query_key = "rpln_search_modal_clear_next" if st.session_state.pop(clear_query_key, False): st.session_state.pop(query_key, None) query = st.text_input( "Search", placeholder="Search chats", label_visibility="collapsed", key=query_key, ) matches = _search_conversations(query or "") if not matches: st.caption("No matching conversations.") return order = ["Today", "Yesterday", "Previous 7 days", "Previous 30 days", "Older"] groups: dict[str, list[dict[str, Any]]] = {k: [] for k in order} for conv in matches: groups[_search_modal_bucket(conv)].append(conv) for label in order: convs = groups.get(label, []) if not convs: continue st.markdown( f'<span class="rpln-search-group">{label}</span>', unsafe_allow_html=True, ) for conv in convs: cid = conv["id"] title = conv.get("title") or "Untitled" ts_raw = conv.get("updated_at") or conv.get("created_at") or "" ts_label = _relative_time(ts_raw) if ts_raw else "" with st.container(key=f"rpln_search_row_{cid}"): col_title, col_ts = st.columns([8, 2], gap="small") with col_title: if st.button(title, key=f"rpln_search_result_{cid}", width="stretch"): _save_conversation() # _load_conversation populates session state directly + # resets the agent on success; nothing further to do. _load_conversation(cid) st.session_state.rpln_search_modal_open = False st.session_state[clear_query_key] = True st.rerun() with col_ts: if ts_label: st.markdown( f'<div class="rpln-search-ts">{ts_label}</div>', unsafe_allow_html=True, ) def _search_modal_bucket(conv: dict[str, Any]) -> str: created_str = conv.get("created_at") or conv.get("updated_at") or "" try: dt = datetime.fromisoformat(created_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) except (ValueError, TypeError): return "Older" now = datetime.now(UTC) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) if dt >= today_start: return "Today" if dt >= today_start - _timedelta(days=1): return "Yesterday" if dt >= today_start - _timedelta(days=7): return "Previous 7 days" if dt >= today_start - _timedelta(days=30): return "Previous 30 days" return "Older" # --------------------------------------------------------------------------- # Settings panel # --------------------------------------------------------------------------- # Appearance knobs — ported 2026-04-22 from the legacy slide-in overlay. # The 5 knobs now live inside the Settings → General tab. All click handling # stays in bridge.js (set-theme / set-accent / set-bubble / set-aprose / # set-density) so no Streamlit rerun fires on each knob change. _TWEAK_THEMES: list[tuple[str, str, str, str]] = [ # (id, label, bg-chip, accent-chip) — v0 exact hex values ("terracotta", "Terracotta", "#121212", "#C96442"), ("graphite", "Graphite", "#141414", "#A8A29E"), ("midnight", "Midnight", "#0E111C", "#7AA2F7"), ("forest", "Forest", "#121A14", "#8AA878"), ("plum", "Plum", "#17121C", "#B69AD6"), ("rose", "Rose", "#1A1015", "#D98A9F"), ("sand", "Sand", "#17130E", "#D9B77A"), ("ocean", "Ocean", "#0E1821", "#6FB3C3"), ] _TWEAK_ACCENTS: list[str] = ["C96442", "7C9B6E", "8A7FBE", "C48B3F", "5E8CA8"] _TWEAK_BUBBLES: list[tuple[str, str]] = [ ("tail", "Tail"), ("soft", "Soft"), ("sharp", "Sharp"), ("flat", "Outline"), ] _TWEAK_APROSE: list[tuple[str, str]] = [("serif", "Serif"), ("sans", "Sans")] _TWEAK_DENSITY: list[tuple[str, str]] = [ ("compact", "Compact"), ("normal", "Normal"), ("spacious", "Spacious"), ] def _build_appearance_section_html() -> str: """Return HTML for the Appearance section inside Settings → General.""" theme_cards = "".join( f'<button class="rpln-tweaks-theme" type="button" ' f'data-rpln-action="set-theme" data-rpln-val="{tid}">' f'<span class="rpln-tweaks-theme-chip" ' f'style="background:linear-gradient(90deg,{bg} 0 55%,{acc} 55% 100%)"></span>' f'<span class="rpln-tweaks-theme-name">{label}</span>' f"</button>" for tid, label, bg, acc in _TWEAK_THEMES ) accent_swatches = "".join( f'<button class="rpln-tweaks-swatch" type="button" title="#{a}" ' f'data-rpln-action="set-accent" data-rpln-val="{a}" ' f'style="background:#{a}"></button>' for a in _TWEAK_ACCENTS ) bubble_opts = "".join( f'<button class="rpln-tweaks-opt" type="button" ' f'data-rpln-action="set-bubble" data-rpln-val="{val}">{label}</button>' for val, label in _TWEAK_BUBBLES ) aprose_opts = "".join( f'<button class="rpln-tweaks-opt" type="button" ' f'data-rpln-action="set-aprose" data-rpln-val="{val}">{label}</button>' for val, label in _TWEAK_APROSE ) density_opts = "".join( f'<button class="rpln-tweaks-opt" type="button" ' f'data-rpln-action="set-density" data-rpln-val="{val}">{label}</button>' for val, label in _TWEAK_DENSITY ) return f""" <div class="rpln-appearance"> <div class="rpln-tweaks-row"> <div class="rpln-tweaks-label">Theme</div> <div class="rpln-tweaks-themes">{theme_cards}</div> </div> <div class="rpln-tweaks-row"> <div class="rpln-tweaks-label">Accent</div> <div class="rpln-tweaks-swatches">{accent_swatches}</div> </div> <div class="rpln-tweaks-row"> <div class="rpln-tweaks-label">User bubble</div> <div class="rpln-tweaks-options">{bubble_opts}</div> </div> <div class="rpln-tweaks-row"> <div class="rpln-tweaks-label">Assistant prose</div> <div class="rpln-tweaks-options">{aprose_opts}</div> </div> <div class="rpln-tweaks-row"> <div class="rpln-tweaks-label">Density</div> <div class="rpln-tweaks-options">{density_opts}</div> </div> </div> """ def _render_settings_panel() -> None: """Settings panel — three tabs: General / Provider & Model / Data.""" # WP-F.05.06 — dim backdrop behind the settings card. Injected outside # any Streamlit dialog so emotion-cache doesn't outrank the !important. st.html('<div class="rpln-settings-scrim"></div>') with st.container(key="rpln_settings_panel"): back_col, title_col = st.columns([1, 9]) with back_col: if st.button( "", key="rpln_settings_back", help="Back to chat", icon=":material/arrow_back:", ): st.session_state.rpln_view = "chat" st.rerun() with title_col: st.markdown('<div class="rpln-settings-title">Settings</div>', unsafe_allow_html=True) tab_g, tab_p, tab_d = st.tabs(["General", "Provider & Model", "Data"]) with tab_g: st.markdown("**Appearance**") st.caption( "Customize theme, typography, and density. " "Changes apply instantly and persist across sessions." ) st.html(_build_appearance_section_html()) with tab_p: _provider_keys = list(_PROVIDER_CONFIG.keys()) cur_provider = st.session_state.get("llm_provider_label", _default_provider_label()) p_idx = _provider_keys.index(cur_provider) if cur_provider in _provider_keys else 0 provider_label: str = st.selectbox( "Provider", _provider_keys, index=p_idx, key="settings_provider" ) cfg = _PROVIDER_CONFIG[provider_label] if cfg["needs_key"]: api_key: str = st.text_input( f"API Key ({cfg['env_var']})", type="password", value=st.session_state.get("api_key_saved", ""), placeholder=f"Paste your {cfg['env_var']} here", key="settings_api_key", ) # pyright: ignore[reportAssignmentType] else: api_key = "" st.info("Ollama runs locally — no API key required.", icon=":material/info:") model_list = cfg.get("models", [cfg["default_model"], _OTHER_MODEL_OPTION]) cur_model = st.session_state.get("llm_model", cfg["default_model"]) m_idx = model_list.index(cur_model) if cur_model in model_list else 0 selected_m: str = st.selectbox("Model", model_list, index=m_idx, key="settings_model") if selected_m == _OTHER_MODEL_OPTION: custom_m: str = st.text_input( "Model name", value="", placeholder=cfg["default_model"], key="settings_model_custom", ) model = custom_m.strip() or cfg["default_model"] else: model = selected_m key_ok = (not cfg["needs_key"]) or bool(api_key) if not key_ok: st.caption("⚠ Enter your API key before applying.") if st.button("Apply", key="settings_apply_llm", type="primary", disabled=not key_ok): wizard.apply_llm_config(provider_label, api_key, model) st.session_state.llm_provider_label = provider_label st.session_state.api_key_saved = api_key st.session_state.llm_model = model st.toast("LLM settings applied.", icon=":material/check_circle:") with tab_d: conv_dir = _conversations_dir() try: rel = conv_dir.relative_to(Path.home()) masked = f"~/{rel}" except ValueError: masked = "…/conversations" st.markdown("**Conversations**") st.caption(masked) st.divider() if st.button( "Clear all conversations", key="rpln_clear_all_btn", type="secondary", icon=":material/delete:", ): st.session_state.rpln_clear_confirm = True st.rerun() if st.session_state.get("rpln_clear_confirm"): st.warning( "This will permanently delete **all** saved conversations.", icon=":material/warning:", ) col_yes, col_no = st.columns(2) with col_yes: if st.button("Confirm delete", key="rpln_clear_confirm_yes", type="primary"): shutil.rmtree(conv_dir, ignore_errors=True) _conversations_dir() ss = st.session_state ss.messages = [] ss.messages_meta = {} new_cid = str(uuid.uuid4()) ss.current_conversation_id = new_cid ss.thread_id = new_cid ss.rpln_clear_confirm = False st.toast("All conversations deleted.", icon=":material/delete:") st.rerun() with col_no: if st.button("Cancel", key="rpln_clear_confirm_no"): st.session_state.rpln_clear_confirm = False st.rerun() # --------------------------------------------------------------------------- # Backward-compat rendering shims (tests import these from web_ui) # --------------------------------------------------------------------------- def _render_conv_title_dropdown() -> None: """Topbar — no rpln_end_chat button (Flow 1 removal).""" shell.topbar() def _render_sidebar() -> None: """Sidebar + profile logoff Streamlit button for bridge wiring.""" shell.sidebar() _list_conversations() _search_conversations("") if st.button("Log off", key="rpln_profile_logoff"): st.query_params["shutdown"] = "1" st.rerun() def _render_export_submenu(conv_id: str) -> None: """Popover with export download buttons for a conversation.""" import importlib.util with st.popover("↓", width="content"): st.markdown("**Export**") st.download_button( "Download as .txt", data=_export_conversation_as_text(conv_id), file_name=f"{conv_id[:8]}.txt", mime="text/plain", key=f"rpln_dl_txt_{conv_id}", ) st.download_button( "Download as .md", data=_export_conversation_as_md(conv_id), file_name=f"{conv_id[:8]}.md", mime="text/markdown", key=f"rpln_dl_md_{conv_id}", ) if _conversation_has_artifacts(conv_id): has_kaleido = importlib.util.find_spec("kaleido") is not None for fmt in ("png", "jpeg"): st.download_button( f"Download plots as .{fmt.upper()}", data=_export_plots_as_zip(conv_id, fmt), file_name=f"{conv_id[:8]}_plots_{fmt}.zip", mime="application/zip", key=f"rpln_dl_{fmt}_{conv_id}", ) for fmt in ("csv", "xlsx"): st.download_button( f"Download tables as .{fmt.upper()}", data=_export_tables_as_zip(conv_id, fmt), file_name=f"{conv_id[:8]}_tables_{fmt}.zip", mime="application/zip", key=f"rpln_dl_{fmt}_{conv_id}", ) if not has_kaleido: st.caption("Install kaleido for plot export: `pip install kaleido`") if __name__ == "__main__": main()