|
|
import streamlit as st |
|
|
import streamlit.components.v1 as components |
|
|
import re |
|
|
import uuid |
|
|
import pandas as pd |
|
|
import time |
|
|
from zoneinfo import ZoneInfo |
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
|
|
|
PRIMARY_USER = "#e2f6e8" |
|
|
PRIMARY_BOT = "#f6f6f6" |
|
|
|
|
|
|
|
|
THEMES = { |
|
|
"νΌμ€νμΉμ€": {"user": "#C6E0D6", "bot": "#FFFFFF", "accent": "#0B8A5A"}, |
|
|
"μ€μΉ΄μ΄λΈλ£¨": {"user": "#C8D9E6", "bot": "#FFFFFF", "accent": "#5D768B"}, |
|
|
"ν¬λ¦¬λ―Έμ€νΈ": {"user": "#E6DAC8", "bot": "#FFFFFF", "accent": "#A48D78"}, |
|
|
} |
|
|
def _get_colors(): |
|
|
theme = st.session_state.get("bubble_theme", "νΌμ€νμΉμ€") |
|
|
return THEMES.get(theme, THEMES["νΌμ€νμΉμ€"]) |
|
|
|
|
|
def render_message( |
|
|
message: str, |
|
|
sender: str = "bot", |
|
|
chips: list[str] | None = None, |
|
|
key: str | None = None, |
|
|
*, |
|
|
animated: bool = False, |
|
|
speed_cps: int = 40, |
|
|
by_word: bool = False, |
|
|
) -> str | None: |
|
|
import re, time |
|
|
|
|
|
palette = _get_colors() |
|
|
|
|
|
show_time = bool(st.session_state.get("show_time", False)) |
|
|
|
|
|
color = palette["user"] if sender == "user" else palette["bot"] |
|
|
align = "right" if sender == "user" else "left" |
|
|
pad = "10px 14px" |
|
|
fsz = "13px" |
|
|
|
|
|
message = str(message).rstrip() |
|
|
if show_time: |
|
|
try: |
|
|
tz = st.session_state.get("tz", "Asia/Seoul") |
|
|
ts_text = datetime.now(ZoneInfo(tz)).strftime("%H:%M") |
|
|
except Exception: |
|
|
ts_text = datetime.now().strftime("%H:%M") |
|
|
else: |
|
|
ts_text = "" |
|
|
|
|
|
|
|
|
|
|
|
def _wrap(html_inner: str, ts_text_local: str = ts_text): |
|
|
bubble = ( |
|
|
f'''<span style="background:{color}; padding:{pad}; border-radius:12px;''' |
|
|
f'''display:inline-block; max-width:80%; font-size:{fsz}; line-height:1.45;''' |
|
|
f'''word-break:break-word;">{html_inner}</span>''' |
|
|
) |
|
|
|
|
|
if ts_text_local: |
|
|
if sender == "user": |
|
|
|
|
|
ts = ( |
|
|
f'''<span style="font-size:11px;color:#888;white-space:nowrap;''' |
|
|
f'''align-self:flex-end;margin:0 2px 2px 0;">{ts_text_local}</span>''' |
|
|
) |
|
|
inner = ts + bubble |
|
|
else: |
|
|
|
|
|
ts = ( |
|
|
f'''<span style="font-size:11px;color:#888;white-space:nowrap;''' |
|
|
f'''align-self:flex-end;margin:0 0 2px 2px;">{ts_text_local}</span>''' |
|
|
) |
|
|
inner = bubble + ts |
|
|
else: |
|
|
inner = bubble |
|
|
|
|
|
row_align = "flex-end" if sender == "user" else "flex-start" |
|
|
return ( |
|
|
f'''<div style="display:flex;align-items:flex-end;justify-content:{row_align};''' |
|
|
f'''gap:2px;margin:6px 0;">{inner}</div>''' |
|
|
) |
|
|
|
|
|
if not animated: |
|
|
st.markdown(_wrap(message), unsafe_allow_html=True) |
|
|
else: |
|
|
ph = st.empty() |
|
|
buf = "" |
|
|
segments = re.split(r'(<[^>]+>)', message) |
|
|
delay = max(0.005, 1.0 / max(1, speed_cps)) |
|
|
for seg in segments: |
|
|
if not seg: |
|
|
continue |
|
|
if seg.startswith("<") and seg.endswith(">"): |
|
|
buf += seg |
|
|
ph.markdown(_wrap(buf), unsafe_allow_html=True) |
|
|
else: |
|
|
if by_word or st.session_state.get("type_by_word", False): |
|
|
for w in seg.split(" "): |
|
|
buf = (buf + " " + w).strip() |
|
|
ph.markdown(_wrap(buf), unsafe_allow_html=True) |
|
|
time.sleep(delay * 5) |
|
|
else: |
|
|
for ch in seg: |
|
|
buf += ch |
|
|
ph.markdown(_wrap(buf), unsafe_allow_html=True) |
|
|
time.sleep(delay) |
|
|
|
|
|
if chips: |
|
|
prefix = f"{key or 'chips'}_{abs(hash(message))}" |
|
|
clicked = render_chip_buttons(chips, key_prefix=prefix) |
|
|
return clicked |
|
|
return None |
|
|
|
|
|
|
|
|
def render_chip_buttons(options, key_prefix="chip", selected_value=None): |
|
|
def slugify(text): |
|
|
return re.sub(r"[^a-zA-Z0-9]+", "-", str(text)).strip("-").lower() or "empty" |
|
|
session_key = f"{key_prefix}_selected" |
|
|
selected_value = st.session_state.get(session_key) |
|
|
|
|
|
|
|
|
st.markdown(f""" |
|
|
<style> |
|
|
div[data-testid="stHorizontalBlock"]{{ |
|
|
display:block !important; |
|
|
}} |
|
|
button[data-testid="stBaseButton-secondary"] {{ |
|
|
background-color: white; |
|
|
border: 1px solid #e3e8e7; |
|
|
border-radius: 20px; |
|
|
padding: 6px 14px; |
|
|
font-size: 14px; |
|
|
cursor: pointer; |
|
|
transition: 0.2s ease-in-out; |
|
|
margin-bottom: -2px; |
|
|
width: 230px; |
|
|
text-align:center; |
|
|
}} |
|
|
|
|
|
button[data-testid="stBaseButton-secondary"]:hover {{ |
|
|
background-color: #e8f0ef; |
|
|
border-color: #009c75; |
|
|
color: #009c75; |
|
|
}} |
|
|
button[data-testid="baseButton-secondary"][disabled]{{ |
|
|
background-color: white; |
|
|
border-color: #009c75; !important; |
|
|
color: #009c75; !important; |
|
|
}} |
|
|
</style> |
|
|
""", unsafe_allow_html=True) |
|
|
|
|
|
|
|
|
clicked_val = None |
|
|
|
|
|
|
|
|
for idx, opt in enumerate(options): |
|
|
if opt is None or (isinstance(opt, float) and pd.isna(opt)) or str(opt).strip()=="": |
|
|
continue |
|
|
|
|
|
is_selected = (opt == selected_value) |
|
|
is_refresh_btn = "λ€λ₯Έ μ¬νμ§ λ³΄κΈ°" in str(opt) |
|
|
disabled = (opt == selected_value) and not is_refresh_btn |
|
|
|
|
|
label = f"{opt}" if is_selected else opt |
|
|
|
|
|
|
|
|
safe_opt = slugify(opt) |
|
|
stable_key = f"{key_prefix}_{idx}_{safe_opt}" |
|
|
|
|
|
if st.button(label, key=stable_key, disabled=disabled): |
|
|
clicked_val = opt |
|
|
|
|
|
return clicked_val |
|
|
|
|
|
|
|
|
|
|
|
def replay_log(chat_container=None): |
|
|
with chat_container: |
|
|
for sender, msg in st.session_state.chat_log: |
|
|
render_message(msg, sender=sender) |
|
|
|
|
|
|
|
|
|
|
|
def log_and_render( |
|
|
msg, |
|
|
sender, |
|
|
chat_container=None, |
|
|
key=None, |
|
|
chips=None, |
|
|
*, |
|
|
animated: bool | None = None, |
|
|
speed_cps: int = 45, |
|
|
by_word: bool = False, |
|
|
): |
|
|
|
|
|
sent_once = st.session_state.setdefault("sent_once", {}) |
|
|
if key and sent_once.get(key): |
|
|
return |
|
|
if key: |
|
|
sent_once[key] = True |
|
|
if st.session_state.chat_log and st.session_state.chat_log[-1] == (sender, msg): |
|
|
return |
|
|
|
|
|
|
|
|
st.session_state.chat_log.append((sender, msg)) |
|
|
|
|
|
|
|
|
if animated is None: |
|
|
animated = (sender == "bot") and st.session_state.get("typewriter_on", True) |
|
|
|
|
|
with chat_container: |
|
|
return render_message( |
|
|
msg, |
|
|
sender=sender, |
|
|
chips=chips, |
|
|
key=key, |
|
|
animated=animated, |
|
|
speed_cps=speed_cps, |
|
|
by_word=by_word, |
|
|
) |