diff --git a/ROADMAP.md b/ROADMAP.md
index 9959159..cc3f1fd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -339,11 +339,12 @@ youlbot-webui/
- [x] `문서 수집` 버튼 크기 적정화 — `scale=0, min_width=200`
#### D2
-- [ ] 헤더 아이콘/로고 추가
-- [ ] `gr.themes.Soft()` 또는 커스텀 테마 적용
-- [ ] 사용자 드롭다운 위치 이동
-- [ ] 채팅 버블 커스텀 CSS
-- [ ] 스트리밍 로딩 표시
+- [x] 헤더 아이콘/로고 추가 — `gr.HTML` 🤖 아이콘 + 타이틀/서브타이틀 레이아웃
+- [x] 커스텀 테마 적용 — `gr.themes.Soft(primary_hue="blue", secondary_hue="indigo", neutral_hue="slate")`
+- [x] 사용자 드롭다운 위치 이동 — 탭 내부 → 헤더 Row 우측 (`scale=0, min_width=160`)
+- [x] 채팅 버블 커스텀 CSS — `.message-wrap .user` 파란 배경, `.message-wrap .bot` 회색 배경
+- [x] 스트리밍 로딩 표시 — `_live_html`에 `streaming-indicator` blink 애니메이션
+- [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS
#### D3
- [ ] 다크 모드 토글
diff --git a/app.py b/app.py
index acc96ed..146a50b 100644
--- a/app.py
+++ b/app.py
@@ -231,7 +231,7 @@ def _live_html(text: str) -> str:
"""스트리밍 중 현재 줄만 보여주는 단순 div (details 미사용 → 닫힘 현상 없음)."""
return (
f'
'
- f'
🤔 분석 중...'
+ f'
⏳ 분석 중...'
f'
{_html.escape(text)} ▌
'
f'
'
)
@@ -274,26 +274,79 @@ def _sources_html(sources: list) -> str:
_CUSTOM_CSS = """
footer { display: none !important; }
+
+/* 입력 영역 */
.send-btn { min-height: 80px !important; align-self: stretch !important; }
+
+/* 헤더 (D2-11) */
+.app-header {
+ align-items: center !important;
+ padding-bottom: 12px !important;
+ border-bottom: 1px solid var(--border-color-primary);
+ margin-bottom: 4px !important;
+}
+.app-header > .wrap, .app-header > div > .wrap {
+ padding: 0 !important;
+ background: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+
+/* 채팅 버블 스타일 (D2-14) */
+.message-wrap .user {
+ background: #dbeafe !important;
+ border-color: #93c5fd !important;
+ border-bottom-right-radius: 4px !important;
+}
+.message-wrap .bot {
+ background: #f8fafc !important;
+ border-color: #e2e8f0 !important;
+ border-bottom-left-radius: 4px !important;
+}
+
+/* 응답 스트리밍 애니메이션 (D2-15) */
+@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
+.streaming-indicator { animation: blink 1.2s ease-in-out infinite; display: inline-block; }
+
+/* 반응형 (D2-16) */
+@media (max-width: 768px) {
+ .send-btn { min-height: 56px !important; }
+ .app-header { flex-wrap: wrap; gap: 8px; }
+ .message-wrap .user, .message-wrap .bot { max-width: 92% !important; }
+}
"""
-with gr.Blocks(title="율봇", css=_CUSTOM_CSS) as demo:
- gr.Markdown("# 율봇\n육아·금융 전문 AI 상담 도우미")
+_THEME = gr.themes.Soft(
+ primary_hue="blue",
+ secondary_hue="indigo",
+ neutral_hue="slate",
+)
+
+with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
+ with gr.Row(elem_classes=["app-header"]):
+ gr.HTML("""
+
+
🤖
+
+
율봇
+
육아·금융 전문 AI 상담 도우미
+
+
+""")
+ user_selector = gr.Dropdown(
+ choices=USER_LABELS,
+ value=DEFAULT_USER,
+ label="사용자",
+ scale=0,
+ min_width=160,
+ )
user_state = gr.State(DEFAULT_USER)
run_ids_state = gr.State([])
with gr.Tab("대화"):
- with gr.Row():
- user_selector = gr.Dropdown(
- choices=USER_LABELS,
- value=DEFAULT_USER,
- label="사용자",
- scale=1,
- )
-
thinking_box = gr.HTML(value="")
- chatbot = gr.Chatbot(label="율봇", height=500)
+ chatbot = gr.Chatbot(label="율봇", height=500, elem_id="main-chatbot")
source_box = gr.HTML(value="")
with gr.Row():
msg_box = gr.Textbox(
@@ -406,5 +459,4 @@ if __name__ == "__main__":
demo.launch(
server_name=container.config().server_host,
server_port=container.config().server_port,
- theme=gr.themes.Soft(),
)