Spaces:
Sleeping
Sleeping
Upload css(타이핑).py
Browse files- css(타이핑).py +186 -0
css(타이핑).py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import streamlit.components.v1 as components
|
| 3 |
+
import re
|
| 4 |
+
import uuid
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
# ────────────────── 말풍선 생성 함수
|
| 9 |
+
# 색상 정의
|
| 10 |
+
PRIMARY_USER = "#e2f6e8"
|
| 11 |
+
PRIMARY_BOT = "#f6f6f6"
|
| 12 |
+
|
| 13 |
+
def render_message(
|
| 14 |
+
message: str,
|
| 15 |
+
sender: str = "bot",
|
| 16 |
+
chips: list[str] | None = None,
|
| 17 |
+
key: str | None = None,
|
| 18 |
+
*,
|
| 19 |
+
animated: bool = False, # ← 추가: 타자 효과 ON/OFF
|
| 20 |
+
speed_cps: int = 40, # ← 추가: 초당 글자 수
|
| 21 |
+
by_word: bool = False, # ← 추가: 단어 단위 출력
|
| 22 |
+
) -> str | None:
|
| 23 |
+
"""
|
| 24 |
+
- `message` : 표시할 텍스트 (HTML 허용)
|
| 25 |
+
- `sender` : "user" | "bot"
|
| 26 |
+
- `chips` : 버튼 형태로 보여 줄 문자열 리스트
|
| 27 |
+
- return : 사용자가 클릭한 칩(문자열) 또는 None
|
| 28 |
+
"""
|
| 29 |
+
color = PRIMARY_USER if sender == "user" else PRIMARY_BOT
|
| 30 |
+
align = "right" if sender == "user" else "left"
|
| 31 |
+
message = str(message).rstrip()
|
| 32 |
+
|
| 33 |
+
# 공통 풍선 래퍼
|
| 34 |
+
def _wrap(html_inner: str) -> str:
|
| 35 |
+
return (
|
| 36 |
+
f'''<div style="text-align:{align}; margin:6px 0;">'''
|
| 37 |
+
f'''<p style="font-size:13px;"></p>'''
|
| 38 |
+
f'''<span style="background:{color}; padding:10px 14px; border-radius:12px;'''
|
| 39 |
+
f'''display:inline-block; max-width:80%; font-size:13px; line-height:1.45;'''
|
| 40 |
+
f'''word-break:break-word;">{html_inner}</span></div>'''
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
if not animated:
|
| 44 |
+
st.markdown(_wrap(message), unsafe_allow_html=True)
|
| 45 |
+
else:
|
| 46 |
+
ph = st.empty()
|
| 47 |
+
buf = ""
|
| 48 |
+
|
| 49 |
+
# 태그는 즉시 추가, 텍스트만 점진 출력(태그 반쯤 잘려 보이는 현상 최소화)
|
| 50 |
+
segments = re.split(r'(<[^>]+>)', message)
|
| 51 |
+
delay = max(0.005, 1.0 / max(1, speed_cps))
|
| 52 |
+
|
| 53 |
+
for seg in segments:
|
| 54 |
+
if not seg:
|
| 55 |
+
continue
|
| 56 |
+
if seg.startswith("<") and seg.endswith(">"):
|
| 57 |
+
# 태그는 구조 유지 위해 한 번에 추가
|
| 58 |
+
buf += seg
|
| 59 |
+
ph.markdown(_wrap(buf), unsafe_allow_html=True)
|
| 60 |
+
else:
|
| 61 |
+
if by_word:
|
| 62 |
+
for w in seg.split(" "):
|
| 63 |
+
buf = (buf + " " + w).strip()
|
| 64 |
+
ph.markdown(_wrap(buf), unsafe_allow_html=True)
|
| 65 |
+
time.sleep(delay * 5) # 단어 단위는 조금 더 여유
|
| 66 |
+
else:
|
| 67 |
+
for ch in seg:
|
| 68 |
+
buf += ch
|
| 69 |
+
ph.markdown(_wrap(buf), unsafe_allow_html=True)
|
| 70 |
+
time.sleep(delay)
|
| 71 |
+
|
| 72 |
+
# 칩 버튼
|
| 73 |
+
if chips:
|
| 74 |
+
prefix = f"{key or 'chips'}_{abs(hash(message))}"
|
| 75 |
+
clicked = render_chip_buttons(chips, key_prefix=prefix)
|
| 76 |
+
return clicked
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
# ────────────────── 칩버튼 생성 함수
|
| 80 |
+
def render_chip_buttons(options, key_prefix="chip", selected_value=None):
|
| 81 |
+
def slugify(text):
|
| 82 |
+
return re.sub(r"[^a-zA-Z0-9]+", "-", str(text)).strip("-").lower() or "empty"
|
| 83 |
+
session_key = f"{key_prefix}_selected"
|
| 84 |
+
selected_value = st.session_state.get(session_key)
|
| 85 |
+
|
| 86 |
+
# 스타일 적용
|
| 87 |
+
st.markdown(f"""
|
| 88 |
+
<style>
|
| 89 |
+
div[data-testid="stHorizontalBlock"]{{
|
| 90 |
+
display:block !important;
|
| 91 |
+
}}
|
| 92 |
+
button[data-testid="stBaseButton-secondary"] {{
|
| 93 |
+
background-color: white;
|
| 94 |
+
border: 1px solid #e3e8e7;
|
| 95 |
+
border-radius: 20px;
|
| 96 |
+
padding: 6px 14px;
|
| 97 |
+
font-size: 14px;
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
transition: 0.2s ease-in-out;
|
| 100 |
+
margin-bottom: -2px;
|
| 101 |
+
width: 230px;
|
| 102 |
+
text-align:center;
|
| 103 |
+
}}
|
| 104 |
+
|
| 105 |
+
button[data-testid="stBaseButton-secondary"]:hover {{
|
| 106 |
+
background-color: #e8f0ef;
|
| 107 |
+
border-color: #009c75;
|
| 108 |
+
color: #009c75;
|
| 109 |
+
}}
|
| 110 |
+
button[data-testid="baseButton-secondary"][disabled]{{
|
| 111 |
+
background-color: white;
|
| 112 |
+
border-color: #009c75; !important;
|
| 113 |
+
color: #009c75; !important;
|
| 114 |
+
}}
|
| 115 |
+
</style>
|
| 116 |
+
""", unsafe_allow_html=True)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
clicked_val = None
|
| 120 |
+
|
| 121 |
+
#cols = st.columns(len(options))
|
| 122 |
+
for idx, opt in enumerate(options):
|
| 123 |
+
if opt is None or (isinstance(opt, float) and pd.isna(opt)) or str(opt).strip()=="":
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
is_selected = (opt == selected_value)
|
| 127 |
+
is_refresh_btn = "다른 여행지 보기" in str(opt)
|
| 128 |
+
disabled = (opt == selected_value) and not is_refresh_btn
|
| 129 |
+
|
| 130 |
+
label = f"{opt}" if is_selected else opt
|
| 131 |
+
|
| 132 |
+
# stable key
|
| 133 |
+
safe_opt = slugify(opt)
|
| 134 |
+
stable_key = f"{key_prefix}_{idx}_{safe_opt}"
|
| 135 |
+
|
| 136 |
+
if st.button(label, key=stable_key, disabled=disabled):
|
| 137 |
+
clicked_val = opt
|
| 138 |
+
|
| 139 |
+
return clicked_val
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ────────────────── 메시지 리플레이 함수
|
| 143 |
+
def replay_log(chat_container=None):
|
| 144 |
+
with chat_container:
|
| 145 |
+
for sender, msg in st.session_state.chat_log:
|
| 146 |
+
render_message(msg, sender=sender)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ────────────────── 메시지 로깅&생성 함수
|
| 150 |
+
def log_and_render(
|
| 151 |
+
msg,
|
| 152 |
+
sender,
|
| 153 |
+
chat_container=None,
|
| 154 |
+
key=None,
|
| 155 |
+
chips=None,
|
| 156 |
+
*,
|
| 157 |
+
animated: bool | None = None,
|
| 158 |
+
speed_cps: int = 45,
|
| 159 |
+
by_word: bool = False,
|
| 160 |
+
):
|
| 161 |
+
# 중복 방지
|
| 162 |
+
sent_once = st.session_state.setdefault("sent_once", {})
|
| 163 |
+
if key and sent_once.get(key):
|
| 164 |
+
return
|
| 165 |
+
if key:
|
| 166 |
+
sent_once[key] = True
|
| 167 |
+
if st.session_state.chat_log and st.session_state.chat_log[-1] == (sender, msg):
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
# 로그 저장(리플레이는 정적표시)
|
| 171 |
+
st.session_state.chat_log.append((sender, msg))
|
| 172 |
+
|
| 173 |
+
# 기본 정책: 봇 메시지는 타자 효과, 유저 메시지는 즉시 표시
|
| 174 |
+
if animated is None:
|
| 175 |
+
animated = (sender == "bot") and st.session_state.get("typewriter_on", True)
|
| 176 |
+
|
| 177 |
+
with chat_container:
|
| 178 |
+
return render_message(
|
| 179 |
+
msg,
|
| 180 |
+
sender=sender,
|
| 181 |
+
chips=chips,
|
| 182 |
+
key=key,
|
| 183 |
+
animated=animated,
|
| 184 |
+
speed_cps=speed_cps,
|
| 185 |
+
by_word=by_word,
|
| 186 |
+
)
|