UI/UX D3: dark mode toggle, chat export, accessibility, onboarding modal
- app.py: _JS with dark mode toggle (localStorage + system preference) (D3-17) - app.py: dark mode CSS overrides for custom chat bubble colors (D3-17) - app.py: export_chat() -> gr.File .md download button in controls row (D3-18) - app.py: JS aria-label/role/aria-live injection for chatbot and inputs (D3-19) - app.py: :focus-visible CSS 3px blue outline for keyboard navigation (D3-19) - app.py: first-visit onboarding modal with localStorage guard (D3-20) - app.py: js=_JS wired into gr.Blocks() - ROADMAP.md: mark all D3 items complete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-3
@@ -347,9 +347,10 @@ youlbot-webui/
|
|||||||
- [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS
|
- [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS
|
||||||
|
|
||||||
#### D3
|
#### D3
|
||||||
- [ ] 다크 모드 토글
|
- [x] 다크 모드 토글 — 헤더 🌙/☀️ 버튼 + `localStorage` 기억 + 시스템 설정 자동 감지
|
||||||
- [ ] 채팅 내보내기 버튼
|
- [x] 채팅 내보내기 — `export_chat` 함수, `.md` 파일 다운로드, `gr.File` 출력
|
||||||
- [ ] 접근성 개선
|
- [x] 접근성 개선 — `aria-label`/`role`/`aria-live` JS 주입, `:focus-visible` 파란 테두리
|
||||||
|
- [x] 온보딩 투어 — 첫 방문 모달 (`localStorage` 체크, "시작하기" 버튼으로 닫기)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ async def reset_chat(user_id):
|
|||||||
return [], []
|
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):
|
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 = `
|
||||||
|
<div style="position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;">
|
||||||
|
<div style="background:#fff;border-radius:20px;padding:36px 32px;max-width:400px;width:100%;box-shadow:0 24px 64px rgba(0,0,0,.25);">
|
||||||
|
<div style="font-size:2.4rem;text-align:center;margin-bottom:12px;">🤖</div>
|
||||||
|
<h2 style="margin:0 0 6px;font-size:1.3rem;font-weight:700;text-align:center;color:#1e293b;">율봇에 오신 것을 환영합니다!</h2>
|
||||||
|
<p style="margin:0 0 20px;text-align:center;color:#64748b;font-size:.875rem;">육아·금융 전문 AI 상담 도우미</p>
|
||||||
|
<ul style="list-style:none;padding:0;margin:0 0 24px;display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💬</span> 아래 입력창에 질문을 입력하세요</li>
|
||||||
|
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💡</span> 예시 질문 클릭으로 빠르게 시작</li>
|
||||||
|
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>📄</span> 문서 등록 탭에서 RAG 문서 추가</li>
|
||||||
|
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>🎤</span> 음성으로도 질문 가능</li>
|
||||||
|
</ul>
|
||||||
|
<button onclick="window.closeOnboarding()" style="width:100%;padding:13px;background:#3b82f6;color:#fff;border:none;border-radius:10px;font-size:.95rem;font-weight:600;cursor:pointer;" onmouseover="this.style.background='#2563eb'" onmouseout="this.style.background='#3b82f6'">시작하기</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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 = """
|
_CUSTOM_CSS = """
|
||||||
footer { display: none !important; }
|
footer { display: none !important; }
|
||||||
|
|
||||||
@@ -314,6 +395,18 @@ footer { display: none !important; }
|
|||||||
.app-header { flex-wrap: wrap; gap: 8px; }
|
.app-header { flex-wrap: wrap; gap: 8px; }
|
||||||
.message-wrap .user, .message-wrap .bot { max-width: 92% !important; }
|
.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(
|
_THEME = gr.themes.Soft(
|
||||||
@@ -322,7 +415,7 @@ _THEME = gr.themes.Soft(
|
|||||||
neutral_hue="slate",
|
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"]):
|
with gr.Row(elem_classes=["app-header"]):
|
||||||
gr.HTML("""
|
gr.HTML("""
|
||||||
<div style="display:flex;align-items:center;gap:14px;padding:4px 0;">
|
<div style="display:flex;align-items:center;gap:14px;padding:4px 0;">
|
||||||
@@ -333,6 +426,15 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
|
gr.HTML("""
|
||||||
|
<div style="display:flex;justify-content:flex-end;align-items:center;height:100%;">
|
||||||
|
<button id="dark-mode-btn" onclick="toggleDarkMode()" title="다크/라이트 모드 전환"
|
||||||
|
style="background:none;border:1.5px solid #e2e8f0;border-radius:8px;cursor:pointer;
|
||||||
|
font-size:1.2rem;padding:6px 10px;line-height:1;color:#64748b;transition:all .15s;"
|
||||||
|
onmouseover="this.style.borderColor='#94a3b8'"
|
||||||
|
onmouseout="this.style.borderColor='#e2e8f0'">🌙</button>
|
||||||
|
</div>
|
||||||
|
""", scale=0, min_width=60)
|
||||||
user_selector = gr.Dropdown(
|
user_selector = gr.Dropdown(
|
||||||
choices=USER_LABELS,
|
choices=USER_LABELS,
|
||||||
value=DEFAULT_USER,
|
value=DEFAULT_USER,
|
||||||
@@ -390,8 +492,12 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
|
|||||||
with gr.Row():
|
with gr.Row():
|
||||||
show_thinking = gr.Checkbox(label="사고 과정 표시", value=True)
|
show_thinking = gr.Checkbox(label="사고 과정 표시", value=True)
|
||||||
use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False)
|
use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False)
|
||||||
with gr.Column(scale=1, min_width=120):
|
with gr.Column(scale=2, min_width=240):
|
||||||
reset_btn = gr.Button("대화 초기화", size="sm")
|
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)
|
tts_output = gr.Audio(label="음성 답변", autoplay=True, visible=False)
|
||||||
use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output])
|
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)
|
send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs)
|
||||||
msg_box.submit(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])
|
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(
|
chatbot.like(
|
||||||
handle_feedback,
|
handle_feedback,
|
||||||
|
|||||||
Reference in New Issue
Block a user