feat: add Gemini/ChatGPT-style 3-dot bounce loading indicator

- _loading_html(): 3 blue-to-indigo gradient dots with bounce animation
- _status_html(): delegates to _loading_html for consistent style
- _live_html(): shows dots + label during thinking/meta streaming
- respond(): first yield shows _loading_html() immediately on send
- CSS: @keyframes dot-bounce (translateY -7px scale 1.05 at 30%)
- fix: move css/theme/js from gr.Blocks() to launch() (Gradio 6 API)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:34:05 +09:00
co-authored by Claude Sonnet 4.6
parent 7f4aeb54e2
commit 51862d62ca
+41 -10
View File
@@ -64,8 +64,8 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids, im
display_msg = f"🖼️ [이미지 첨부]\n{message}" if message.strip() else "🖼️ [이미지 첨부]"
history.append({"role": "user", "content": display_msg})
history.append({"role": "assistant", "content": ""})
# 첫 메시지 → 웰컴 뷰 숨기고 챗봇 컬럼 표시 (D4-2)
yield history, "", None, run_ids, "", "", None, gr.update(visible=False), gr.update(visible=True)
# 첫 메시지 → 웰컴 뷰 숨기고 챗봇 컬럼 표시 + 로딩 인디케이터
yield history, "", None, run_ids, _loading_html(), "", None, gr.update(visible=False), gr.update(visible=True)
collected_run_id = None
tts_text = ""
@@ -248,11 +248,29 @@ def _last_line(text: str) -> str:
return lines[-1] if lines else text.strip()
def _loading_html(label: str = "율봇이 생각하고 있어요") -> str:
"""Gemini/ChatGPT 스타일 3-dot 바운스 로딩 인디케이터"""
return (
f'<div style="display:flex;align-items:center;gap:12px;'
f'padding:14px 18px;background:#f8fafc;border-radius:14px;margin-bottom:8px;">'
f'<div class="loading-dots">'
f'<span class="dot"></span><span class="dot"></span><span class="dot"></span>'
f'</div>'
f'<span style="font-size:.88rem;color:#64748b;">{_html.escape(label)}</span>'
f'</div>'
)
def _live_html(text: str) -> str:
return (
f'<div style="{_BOX_STYLE}">'
f'<strong class="streaming-indicator">⏳ 분석 중...</strong>'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)} ▌</div>'
f'<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">'
f'<div class="loading-dots">'
f'<span class="dot"></span><span class="dot"></span><span class="dot"></span>'
f'</div>'
f'<strong style="font-size:.88rem;color:#64748b;">분석 중...</strong>'
f'</div>'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)}</div>'
f'</div>'
)
@@ -267,11 +285,7 @@ def _thinking_html(text: str) -> str:
def _status_html(status: str) -> str:
return (
f'<div style="{_BOX_STYLE}">'
f'<strong>🤔 {_html.escape(status)}</strong>'
f'</div>'
)
return _loading_html(status)
def _sources_html(sources: list) -> str:
@@ -463,6 +477,20 @@ _JS = """
_CUSTOM_CSS = """
footer { display: none !important; }
/* ── 로딩 인디케이터 (Gemini/ChatGPT 3-dot 바운스) ── */
.loading-dots { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; }
.loading-dots .dot {
width: 8px; height: 8px; border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #818cf8 100%);
animation: dot-bounce 1.4s ease-in-out infinite;
}
.loading-dots .dot:nth-child(2) { animation-delay: .18s; }
.loading-dots .dot:nth-child(3) { animation-delay: .36s; }
@keyframes dot-bounce {
0%, 60%, 100% { transform: translateY(0) scale(0.65); opacity: 0.35; }
30% { transform: translateY(-7px) scale(1.05); opacity: 1; }
}
/* 스트리밍 중 파란 테두리 제거 (.generating 클래스가 원인) */
.wrap.generating {
border-color: var(--border-color-primary) !important;
@@ -726,7 +754,7 @@ _THEME = gr.themes.Soft(
# ── UI 구성 ───────────────────────────────────────────────────────
with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo:
with gr.Blocks(title="율봇") as demo:
user_state = gr.State(DEFAULT_USER)
run_ids_state = gr.State([])
@@ -925,4 +953,7 @@ if __name__ == "__main__":
demo.launch(
server_name=container.config().server_host,
server_port=container.config().server_port,
theme=_THEME,
css=_CUSTOM_CSS,
js=_JS,
)