diff --git a/ROADMAP.md b/ROADMAP.md index cc3f1fd..1ec6636 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,9 +347,10 @@ youlbot-webui/ - [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS #### D3 -- [ ] 다크 모드 토글 -- [ ] 채팅 내보내기 버튼 -- [ ] 접근성 개선 +- [x] 다크 모드 토글 — 헤더 🌙/☀️ 버튼 + `localStorage` 기억 + 시스템 설정 자동 감지 +- [x] 채팅 내보내기 — `export_chat` 함수, `.md` 파일 다운로드, `gr.File` 출력 +- [x] 접근성 개선 — `aria-label`/`role`/`aria-live` JS 주입, `:focus-visible` 파란 테두리 +- [x] 온보딩 투어 — 첫 방문 모달 (`localStorage` 체크, "시작하기" 버튼으로 닫기) --- diff --git a/app.py b/app.py index 146a50b..0fff746 100644 --- a/app.py +++ b/app.py @@ -164,6 +164,24 @@ async def reset_chat(user_id): return [], [] +async def export_chat(history): + if not history: + return gr.update(visible=False) + from datetime import datetime + import tempfile + lines = [f"# 율봇 대화 내보내기\n_내보낸 시각: {datetime.now().strftime('%Y-%m-%d %H:%M')}_\n\n"] + for msg in history: + role = "👤 사용자" if msg["role"] == "user" else "🤖 율봇" + content = str(msg.get("content") or "") + lines.append(f"### {role}\n\n{content}\n\n---\n\n") + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".md", delete=False, encoding="utf-8", prefix="youlbot_chat_" + ) + tmp.write("".join(lines)) + tmp.close() + return gr.update(value=tmp.name, visible=True) + + # ── 문서 관리 ───────────────────────────────────────────────────── async def ingest_files(files): @@ -272,6 +290,69 @@ def _sources_html(sources: list) -> str: ) +_JS = """ +() => { + const htmlEl = document.documentElement; + + // D3-17: 다크 모드 토글 + window.toggleDarkMode = function() { + htmlEl.classList.toggle('dark'); + const isDark = htmlEl.classList.contains('dark'); + localStorage.setItem('youlbot_theme', isDark ? 'dark' : 'light'); + const btn = document.getElementById('dark-mode-btn'); + if (btn) btn.textContent = isDark ? '☀️' : '🌙'; + }; + const saved = localStorage.getItem('youlbot_theme'); + if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + htmlEl.classList.add('dark'); + } + + // D3-19: aria 레이블 설정 + function addAriaLabels() { + const ta = document.querySelector('textarea[placeholder*="질문"]'); + if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)'); + document.querySelectorAll('.send-btn').forEach(b => b.setAttribute('aria-label', '메시지 전송')); + const cb = document.getElementById('main-chatbot'); + if (cb) { cb.setAttribute('role', 'log'); cb.setAttribute('aria-live', 'polite'); cb.setAttribute('aria-label', '대화 내용'); } + } + + // D3-20: 첫 방문 온보딩 모달 + function showOnboarding() { + if (localStorage.getItem('youlbot_onboarded')) return; + const modal = document.createElement('div'); + modal.id = 'youlbot-onboarding'; + modal.innerHTML = ` +
+
+
🤖
+

율봇에 오신 것을 환영합니다!

+

육아·금융 전문 AI 상담 도우미

+ + +
+
`; + document.body.appendChild(modal); + } + window.closeOnboarding = function() { + const m = document.getElementById('youlbot-onboarding'); + if (m) m.remove(); + localStorage.setItem('youlbot_onboarded', '1'); + }; + + setTimeout(function() { + addAriaLabels(); + showOnboarding(); + const btn = document.getElementById('dark-mode-btn'); + if (btn && htmlEl.classList.contains('dark')) btn.textContent = '☀️'; + }, 1500); +} +""" + _CUSTOM_CSS = """ footer { display: none !important; } @@ -314,6 +395,18 @@ footer { display: none !important; } .app-header { flex-wrap: wrap; gap: 8px; } .message-wrap .user, .message-wrap .bot { max-width: 92% !important; } } + +/* 접근성: 포커스 표시 강화 (D3-19) */ +:focus-visible { + outline: 3px solid #3b82f6 !important; + outline-offset: 2px !important; + border-radius: 4px; +} + +/* 다크 모드 커스텀 요소 오버라이드 (D3-17) */ +.dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; } +.dark .message-wrap .bot { background: #1e293b !important; border-color: #334155 !important; } +.dark .app-header { border-color: #334155 !important; } """ _THEME = gr.themes.Soft( @@ -322,7 +415,7 @@ _THEME = gr.themes.Soft( neutral_hue="slate", ) -with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo: +with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo: with gr.Row(elem_classes=["app-header"]): gr.HTML("""
@@ -333,6 +426,15 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
""") + gr.HTML(""" +
+ +
+""", scale=0, min_width=60) user_selector = gr.Dropdown( choices=USER_LABELS, value=DEFAULT_USER, @@ -390,8 +492,12 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo: with gr.Row(): show_thinking = gr.Checkbox(label="사고 과정 표시", value=True) use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False) - with gr.Column(scale=1, min_width=120): - reset_btn = gr.Button("대화 초기화", size="sm") + with gr.Column(scale=2, min_width=240): + with gr.Row(): + export_btn = gr.Button("💾 내보내기", size="sm", min_width=100) + reset_btn = gr.Button("대화 초기화", size="sm", min_width=100) + + export_file = gr.File(label="내보내기 파일", visible=False) tts_output = gr.Audio(label="음성 답변", autoplay=True, visible=False) use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output]) @@ -412,6 +518,7 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo: send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs) msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs) reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state]) + export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file]) chatbot.like( handle_feedback,