feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen

- pages/2_Демо-консоль.py: persona selector, virtual clock bar (+1 h, to 08:00/20:00,
  next day, reset with confirm), fallback patient web chat (buttons from Reply.buttons,
  kind styling, outbox drained after every action and on a 2 s fragment poll), scenario panel
- pages/3_Сплит-экран.py: patient chat or PATIENT_WEB_URL iframe on the left, compact doctor
  panel (patients, live alerts, red banner on a new acute alert) on the right
- console/playback.py: data/synthetic/<persona>.jsonl runner (advance_to/press/text/
  expect_color/start), date chaining between days, pause on failure with retry/skip/manual
- console/api.py: own HTTP client over the frozen API contract, ApiError only

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:46:14 +03:00
co-authored by Claude Opus 5.5
parent 178cab5b1b
commit 260422138a
11 changed files with 1782 additions and 0 deletions
@@ -0,0 +1,33 @@
"""Demo console (#17, ADR-004/005): web chat of the patient, demo clock and scenario playback.
Everything goes through the HTTP API (`API_BASE_URL`); the page keeps working when the backend serves fixtures.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
persona = w.select_persona()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль")
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col:
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat")
with play_col:
render_playback_panel(persona)
@@ -0,0 +1,58 @@
"""Split screen for the projector (#17): patient on the left, doctor on the right, clock on top.
Left: the patient web app in an iframe when `PATIENT_WEB_URL` is set (React app, e.g. http://localhost:5173),
otherwise the console's fallback web chat. Right: compact doctor panel polled every 2 s with a red banner
on a new acute alert.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown(
"<style>.block-container{padding-top:1.2rem;max-width:100%} "
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona()
w.show_flash()
token = persona["token"]
top, play = st.columns([6, 1.3], vertical_alignment="center")
with top:
w.render_clock_bar(persona, key="split-clock", with_reset=False)
with play:
scenario, _ = w.scenario_for(persona)
runner = w.make_runner(persona, delay=0.8)
nxt = runner.next_day(scenario) if scenario.days else None
clicked = st.button(
f"▶ Сценарий: день {nxt.day}" if nxt else "Сценарий проигран",
disabled=nxt is None,
width="stretch",
help="Проиграть следующий день из data/synthetic/<персона>.jsonl",
)
if clicked and nxt is not None:
w.execute(runner.play_day(scenario, nxt.day), w.playback_state(token), title=f"День {nxt.day}")
st.rerun()
left, right = st.columns([1.1, 1], gap="large")
with left:
url = w.patient_web_url()
if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}")
else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat")
with right:
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc")