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:
@@ -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 = `
|
||||
<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 = """
|
||||
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("""
|
||||
<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>
|
||||
""")
|
||||
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(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user