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 상담 도우미
+
+ - 💬 아래 입력창에 질문을 입력하세요
+ - 💡 예시 질문 클릭으로 빠르게 시작
+ - 📄 문서 등록 탭에서 RAG 문서 추가
+ - 🎤 음성으로도 질문 가능
+
+
+
+
`;
+ 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,