From f987df5da5591f844ff0fbac15c89ac928234a15 Mon Sep 17 00:00:00 2001 From: shinalok Date: Tue, 2 Jun 2026 16:58:18 +0900 Subject: [PATCH] UI/UX D4: Gemini-style sidebar, welcome glow, pill input, chips, icon controls, history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.py: remove gr.Tab, add youlbot-sidebar Column + 3 panel Columns (D4-1) - app.py: _welcome_html() radial gradient glow + personalized greeting (D4-2) - app.py: pill-input CSS border-radius:24px, circular send button (D4-3) - app.py: _example_chips_html() + JS fillInput() replacing gr.Examples (D4-4) - app.py: icon buttons πŸ’Ύ/πŸ—‘, compact ctrl-check layout (D4-5) - app.py: JS saveChatToHistory/renderChatHistory localStorage max 20 (D4-6) - app.py: sidebar toggle (☰), dark mode btn, user selector in sidebar - app.py: respond/reset_chat/switch_user yield welcome_view visibility - ROADMAP.md: mark all D4 items complete Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 12 +- app.py | 656 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 480 insertions(+), 188 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1ca9354..5b20737 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -465,12 +465,12 @@ youlbot-webui/ #### D4 체크리슀트 -- [ ] D4-2: μ›°μ»΄ λ·° β€” κ°œμΈν™” 인사 + κΈ€λ‘œμš° κ·ΈλΌλ””μ–ΈνŠΈ λ°°κ²½ -- [ ] D4-3: Pill μž…λ ₯μ°½ CSS + 쀑앙/ν•˜λ‹¨ μœ„μΉ˜ μ „ν™˜ -- [ ] D4-4: μ˜ˆμ‹œ 질문 β†’ `gr.HTML` chip μΉ΄λ“œ μŠ€νƒ€μΌ -- [ ] D4-5: 컨트둀 μ•„μ΄μ½˜ν™” + βš™ νŒμ—… νŒ¨λ„ -- [ ] D4-1: 쒌츑 μ‚¬μ΄λ“œλ°” λ ˆμ΄μ•„μ›ƒ (νƒ­ 제거) -- [ ] D4-6: λŒ€ν™” 이λ ₯ localStorage μ €μž₯ + μ‚¬μ΄λ“œλ°” ν‘œμ‹œ +- [x] D4-2: μ›°μ»΄ λ·° β€” `_welcome_html(user)` κΈ€λ‘œμš° κ·ΈλΌλ””μ–ΈνŠΈ + κ°œμΈν™” 인사, 첫 전솑 μ‹œ `visible=False` +- [x] D4-3: Pill μž…λ ₯μ°½ β€” `border-radius:24px`, 전솑 λ²„νŠΌ μ›ν˜•(↑), `pill-input` CSS +- [x] D4-4: μ˜ˆμ‹œ 질문 β€” `gr.Examples` 제거 β†’ `_example_chips_html()` + JS `fillInput()` +- [x] D4-5: 컨트둀 μ•„μ΄μ½˜ν™” β€” μ²΄ν¬λ°•μŠ€ μ†Œν˜•ν™”, πŸ’Ύ/πŸ—‘ μ•„μ΄μ½˜ λ²„νŠΌ, `control-row` CSS +- [x] D4-1: 쒌츑 μ‚¬μ΄λ“œλ°” β€” `gr.Tab` 제거 β†’ `youlbot-sidebar` + `panel_*` Column 3개, λ„€λΉ„ λ²„νŠΌ μ „ν™˜ +- [x] D4-6: λŒ€ν™” 이λ ₯ β€” JS `saveChatToHistory` + `renderChatHistory` + localStorage 20개 --- diff --git a/app.py b/app.py index 0fff746..c9052ea 100644 --- a/app.py +++ b/app.py @@ -30,7 +30,7 @@ container = Container() USER_LABELS = ["아둝", "근혜", "λ„μœ¨", "ν•˜μœ¨"] DEFAULT_USER = "아둝" -# ── STT (Whisper) β€” 둜컬 μ‹€ν–‰ μœ μ§€ ────────────────────────────── +# ── STT (Whisper) ───────────────────────────────────────────────── _whisper_model = None @@ -50,11 +50,11 @@ def transcribe_audio(filepath: str) -> str: return result["text"].strip() -# ── μ±„νŒ… ───────────────────────────────────────────────────────── +# ── μ±„νŒ… ────────────────────────────────────────────────────────── async def respond(message, history, show_thinking, user_id, use_tts, run_ids, image_path): if not message.strip() and not image_path: - yield history, "", None, run_ids, "", "", None + yield history, "", None, run_ids, "", "", None, gr.update() return history = list(history) @@ -64,14 +64,14 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids, im display_msg = f"πŸ–ΌοΈ [이미지 첨뢀]\n{message}" if message.strip() else "πŸ–ΌοΈ [이미지 첨뢀]" history.append({"role": "user", "content": display_msg}) history.append({"role": "assistant", "content": ""}) - yield history, "", None, run_ids, "", "", None # boxes μ΄ˆκΈ°ν™” + 이미지 μ΄ˆκΈ°ν™” + # 첫 λ©”μ‹œμ§€ β†’ μ›°μ»΄ λ·° 숨기기 (D4-2) + yield history, "", None, run_ids, "", "", None, gr.update(visible=False) - collected_run_id: str | None = None + collected_run_id = None tts_text = "" thinking_acc = "" thinking_text = "" thinking_finalized = False - source_box_html = "" try: async for token, run_id in container.chat_service().chat( @@ -81,54 +81,48 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids, im collected_run_id = run_id break - # μ¦‰μ‹œ μƒνƒœ β€” thinking_acc에 λˆ„μ  μ•ˆ 함 if isinstance(token, dict) and "__status" in token: if not thinking_acc: - yield history, "", None, run_ids, _status_html(token["__status"]), gr.update(), gr.update() + yield history, "", None, run_ids, _status_html(token["__status"]), gr.update(), gr.update(), gr.update() continue - # 사고 κ³Όμ •(LLM thinking) β€” ν˜„μž¬ μ€„λ§Œ live_html둜 ν‘œμ‹œ if isinstance(token, dict) and "__thinking" in token: thinking_text += token["__thinking"] thinking_acc += token["__thinking"] - yield history, "", None, run_ids, _live_html(_last_line(thinking_text)), gr.update(), gr.update() + yield history, "", None, run_ids, _live_html(_last_line(thinking_text)), gr.update(), gr.update(), gr.update() continue - # μ§„ν–‰ 둜그(LangGraph, 검색 λ“±) β€” λ©”μ‹œμ§€ 전체λ₯Ό live_html둜 ν‘œμ‹œ if isinstance(token, dict) and "__meta" in token: thinking_acc += token["__meta"] live = token["__meta"].strip() if live: - yield history, "", None, run_ids, _live_html(live), gr.update(), gr.update() + yield history, "", None, run_ids, _live_html(live), gr.update(), gr.update(), gr.update() continue - # RAG 좜처 β€” 별도 source_box둜 ν‘œμ‹œ if isinstance(token, dict) and "__sources" in token: - source_box_html = _sources_html(token["__sources"]) - yield history, "", None, run_ids, gr.update(), source_box_html, gr.update() + yield history, "", None, run_ids, gr.update(), _sources_html(token["__sources"]), gr.update(), gr.update() continue - # 첫 λ‹΅λ³€ 토큰 도착 β€” 전체λ₯Ό details둜 μ „ν™˜ (μ ‘νžŒ μƒνƒœ) if thinking_acc and not thinking_finalized: thinking_finalized = True - yield history, "", None, run_ids, _thinking_html(thinking_acc), gr.update(), gr.update() + yield history, "", None, run_ids, _thinking_html(thinking_acc), gr.update(), gr.update(), gr.update() tts_text += token history[-1]["content"] += token - yield history, "", None, run_ids, gr.update(), gr.update(), gr.update() + yield history, "", None, run_ids, gr.update(), gr.update(), gr.update(), gr.update() except Exception as e: history[-1]["content"] += f"\n\n[였λ₯˜: {e}]" - yield history, "", None, run_ids, gr.update(), gr.update(), gr.update() + yield history, "", None, run_ids, gr.update(), gr.update(), gr.update(), gr.update() return run_ids.append(collected_run_id) if use_tts: audio_path = await container.tts_service().speak(tts_text) - yield history, "", audio_path, run_ids, gr.update(), gr.update(), gr.update() + yield history, "", audio_path, run_ids, gr.update(), gr.update(), gr.update(), gr.update() else: - yield history, "", None, run_ids, gr.update(), gr.update(), gr.update() + yield history, "", None, run_ids, gr.update(), gr.update(), gr.update(), gr.update() async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): @@ -141,11 +135,9 @@ async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): return asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant") run_id = run_ids[asst_turn] if run_ids and asst_turn < len(run_ids) else None - user_msg = str(history[idx - 1]["content"]) if idx > 0 else "" asst_msg = str(history[idx]["content"]) rating = 1 if like_data.liked else -1 - try: await container.chat_service().save_feedback(user_id, user_msg, asst_msg, rating, run_id) except Exception as e: @@ -153,7 +145,7 @@ async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): def switch_user(user_id): - return [], [] + return [], [], gr.update(value=_welcome_html(user_id), visible=True) async def reset_chat(user_id): @@ -161,7 +153,7 @@ async def reset_chat(user_id): await container.chat_service().reset(user_id) except Exception as e: logger.error("λŒ€ν™” μ΄ˆκΈ°ν™” μ‹€νŒ¨: %s", e) - return [], [] + return [], [], gr.update(visible=True) async def export_chat(history): @@ -227,7 +219,19 @@ async def delete_doc(source): return f"였λ₯˜: {e}", await list_docs() -# ── UI ꡬ성 ────────────────────────────────────────────────────── +# ── νŒ¨λ„ μ „ν™˜ (D4-1) ────────────────────────────────────────────── + +def show_chat_panel(): + return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False) + +def show_doc_register_panel(): + return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False) + +def show_doc_manage_panel(): + return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True) + + +# ── HTML 헬퍼 ───────────────────────────────────────────────────── _BOX_STYLE = ( "background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;" @@ -240,13 +244,11 @@ _CONTENT_STYLE = ( def _last_line(text: str) -> str: - """ν˜„μž¬ μ§„ν–‰ 쀑인 λ§ˆμ§€λ§‰ λΉ„μ–΄μžˆμ§€ μ•Šμ€ 쀄 λ°˜ν™˜.""" lines = [l for l in text.split("\n") if l.strip()] return lines[-1] if lines else text.strip() def _live_html(text: str) -> str: - """슀트리밍 쀑 ν˜„μž¬ μ€„λ§Œ λ³΄μ—¬μ£ΌλŠ” λ‹¨μˆœ div (details λ―Έμ‚¬μš© β†’ λ‹«νž˜ ν˜„μƒ μ—†μŒ).""" return ( f'
' f'⏳ 뢄석 쀑...' @@ -256,7 +258,6 @@ def _live_html(text: str) -> str: def _thinking_html(text: str) -> str: - """μ™„λ£Œ ν›„ 전체 λ‚΄μš©μ„ μ ‘κΈ°/펼치기둜 ν‘œμ‹œ.""" return ( f'
' f'πŸ’­ 뢄석 μ™„λ£Œ' @@ -266,7 +267,6 @@ def _thinking_html(text: str) -> str: def _status_html(status: str) -> str: - """λ‚΄μš© 없이 μƒνƒœλ§Œ ν‘œμ‹œν•˜λŠ” λ‹¨μˆœ 헀더.""" return ( f'
' f'πŸ€” {_html.escape(status)}' @@ -275,7 +275,6 @@ def _status_html(status: str) -> str: def _sources_html(sources: list) -> str: - """RAG 좜처 λͺ©λ‘μ„ μ ‘κΈ°/펼치기둜 ν‘œμ‹œ.""" items = "".join( f"
  • {_html.escape(s['filename'])}" + (f" β€” {s['page']}νŽ˜μ΄μ§€" if "page" in s else "") @@ -290,6 +289,46 @@ def _sources_html(sources: list) -> str: ) +def _welcome_html(user_name: str) -> str: + """D4-2: κ°œμΈν™” μ›°μ»΄ λ·° β€” κΈ€λ‘œμš° κ·ΈλΌλ””μ–ΈνŠΈ + 인사말""" + return f""" +
    +
    +
    +
    πŸ€–
    +

    + {_html.escape(user_name)}λ‹˜, μ‹œμž‘ν•΄ λ³ΌκΉŒμš”? +

    +

    + μœ‘μ•„Β·κΈˆμœ΅ μ „λ¬Έ AI 상담 λ„μš°λ―Έμž…λ‹ˆλ‹€. +

    +
    +
    +""" + + +_EXAMPLE_QUESTIONS = [ + "μœ‘μ•„νœ΄μ§ κΈ‰μ—¬ μ‹ μ²­ 방법을 μ•Œλ €μ£Όμ„Έμš”", + "어린이집 μž…μ†Œ λŒ€κΈ° 기간은 μ–Όλ§ˆλ‚˜ κ±Έλ¦¬λ‚˜μš”?", + "아이 μ˜λ£ŒλΉ„ μ„ΈκΈˆ κ³΅μ œλŠ” μ–΄λ–»κ²Œ ν•˜λ‚˜μš”?", +] + + +def _example_chips_html() -> str: + """D4-4: μ˜ˆμ‹œ 질문 chip μΉ΄λ“œ""" + chips = "".join( + f'' + for q in _EXAMPLE_QUESTIONS + ) + return f'
    {chips}
    ' + + +# ── JavaScript (D3 + D4) ────────────────────────────────────────── + _JS = """ () => { const htmlEl = document.documentElement; @@ -307,16 +346,84 @@ _JS = """ htmlEl.classList.add('dark'); } - // D3-19: aria λ ˆμ΄λΈ” μ„€μ • + // D4-1: μ‚¬μ΄λ“œλ°” μ ‘κΈ°/펼치기 + window.toggleSidebar = function() { + const sb = document.querySelector('.youlbot-sidebar'); + if (!sb) return; + sb.classList.toggle('collapsed'); + const isCollapsed = sb.classList.contains('collapsed'); + localStorage.setItem('youlbot_sidebar', isCollapsed ? 'collapsed' : 'expanded'); + }; + + // D4-1: λ„€λΉ„κ²Œμ΄μ…˜ ν™œμ„± μƒνƒœ ν‘œμ‹œ + window.setActiveNav = function(id) { + document.querySelectorAll('.nav-btn button').forEach(b => { + b.classList.remove('nav-active'); + }); + const el = document.getElementById(id); + if (el) el.classList.add('nav-active'); + }; + + // D4-3/4: μ˜ˆμ‹œ 질문 chip 클릭 β†’ μž…λ ₯μ°½ μ±„μš°κΈ° + window.fillInput = function(text) { + const ta = document.querySelector('.pill-input textarea'); + if (!ta) return; + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(ta, text); + ta.dispatchEvent(new Event('input', { bubbles: true })); + ta.focus(); + }; + + // D4-5: μ„€μ • νŒ¨λ„ ν† κΈ€ + window.toggleControlPanel = function() { + const panel = document.getElementById('control-panel'); + if (!panel) return; + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + }; + document.addEventListener('click', function(e) { + const panel = document.getElementById('control-panel'); + const btn = document.getElementById('settings-btn'); + if (panel && btn && !panel.contains(e.target) && !btn.contains(e.target)) { + panel.style.display = 'none'; + } + }); + + // D4-6: λŒ€ν™” 이λ ₯ μ €μž₯ 및 λ Œλ”λ§ + window.saveChatToHistory = function(firstQuestion) { + if (!firstQuestion || !firstQuestion.trim()) return; + const history = JSON.parse(localStorage.getItem('youlbot_history') || '[]'); + const title = firstQuestion.trim().substring(0, 28) + (firstQuestion.length > 28 ? '…' : ''); + const idx = history.findIndex(h => h.title === title); + if (idx >= 0) history.splice(idx, 1); + history.unshift({ id: Date.now(), title, time: new Date().toLocaleDateString('ko-KR') }); + if (history.length > 20) history.splice(20); + localStorage.setItem('youlbot_history', JSON.stringify(history)); + window.renderChatHistory(); + }; + + window.renderChatHistory = function() { + const list = document.getElementById('chat-history-list'); + if (!list) return; + const history = JSON.parse(localStorage.getItem('youlbot_history') || '[]'); + list.innerHTML = history.length === 0 + ? '
    λŒ€ν™” 이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€
    ' + : history.map(h => + `
    + πŸ’¬ + ${h.title} +
    ` + ).join(''); + }; + + // D3-19: aria λ ˆμ΄λΈ” function addAriaLabels() { - const ta = document.querySelector('textarea[placeholder*="질문"]'); + const ta = document.querySelector('.pill-input textarea'); 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', 'λŒ€ν™” λ‚΄μš©'); } + if (cb) { cb.setAttribute('role', 'log'); cb.setAttribute('aria-live', 'polite'); } } - // D3-20: 첫 λ°©λ¬Έ μ˜¨λ³΄λ”© λͺ¨λ‹¬ + // D3-20: 첫 λ°©λ¬Έ μ˜¨λ³΄λ”© function showOnboarding() { if (localStorage.getItem('youlbot_onboarded')) return; const modal = document.createElement('div'); @@ -345,35 +452,176 @@ _JS = """ }; setTimeout(function() { + // μ‚¬μ΄λ“œλ°” μƒνƒœ 볡원 + const sbState = localStorage.getItem('youlbot_sidebar'); + if (sbState === 'collapsed') { + const sb = document.querySelector('.youlbot-sidebar'); + if (sb) sb.classList.add('collapsed'); + } + // 닀크λͺ¨λ“œ λ²„νŠΌ μ•„μ΄μ½˜ 동기화 + const dmBtn = document.getElementById('dark-mode-btn'); + if (dmBtn && htmlEl.classList.contains('dark')) dmBtn.textContent = 'β˜€οΈ'; + // aria, μ˜¨λ³΄λ”©, 이λ ₯ addAriaLabels(); showOnboarding(); - const btn = document.getElementById('dark-mode-btn'); - if (btn && htmlEl.classList.contains('dark')) btn.textContent = 'β˜€οΈ'; + window.renderChatHistory(); }, 1500); } """ +# ── CSS ─────────────────────────────────────────────────────────── + _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; +/* ── 전체 λ ˆμ΄μ•„μ›ƒ (D4-1) ── */ +.app-layout { + min-height: calc(100vh - 60px) !important; + gap: 0 !important; + align-items: stretch !important; } -.app-header > .wrap, .app-header > div > .wrap { - padding: 0 !important; + +/* ── μ‚¬μ΄λ“œλ°” (D4-1) ── */ +.youlbot-sidebar { + width: 260px !important; + min-width: 260px !important; + max-width: 260px !important; + border-right: 1px solid var(--border-color-primary); + padding: 16px 12px !important; + display: flex; + flex-direction: column; + gap: 4px; + transition: width .25s, min-width .25s, max-width .25s; + overflow: hidden; + flex-shrink: 0 !important; +} +.youlbot-sidebar.collapsed { + width: 64px !important; + min-width: 64px !important; + max-width: 64px !important; +} +.youlbot-sidebar.collapsed .sidebar-label, +.youlbot-sidebar.collapsed .sidebar-brand-text, +.youlbot-sidebar.collapsed .history-section, +.youlbot-sidebar.collapsed .sidebar-user { display: none !important; } + +/* μ‚¬μ΄λ“œλ°” 헀더 */ +.sidebar-header-html .wrap { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 !important; } + +/* λ„€λΉ„κ²Œμ΄μ…˜ λ²„νŠΌ (D4-1) */ +.nav-btn button { + width: 100% !important; + text-align: left !important; background: transparent !important; border: none !important; + border-radius: 10px !important; + padding: 10px 14px !important; + color: #475569 !important; + font-size: 0.9rem !important; + font-weight: 500 !important; + justify-content: flex-start !important; + transition: background .15s !important; box-shadow: none !important; } +.nav-btn button:hover { background: #f1f5f9 !important; } +.nav-btn button.nav-active { + background: #eff6ff !important; + color: #2563eb !important; + font-weight: 600 !important; +} -/* μ±„νŒ… 버블 μŠ€νƒ€μΌ (D2-14) */ +/* λŒ€ν™” 이λ ₯ (D4-6) */ +.history-section .wrap { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 4px !important; } +.history-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 8px; border-radius: 8px; cursor: pointer; + font-size: .82rem; color: #475569; + white-space: nowrap; overflow: hidden; + transition: background .12s; +} +.history-item:hover { background: #f8fafc; } +.history-title { overflow: hidden; text-overflow: ellipsis; } + +/* ── 메인 μ½˜ν…μΈ  ── */ +.main-content { padding: 0 !important; flex: 1 !important; min-width: 0 !important; } +.main-content > .wrap { padding: 20px 28px !important; } + +/* ── μ›°μ»΄ λ·° (D4-2) ── */ +.welcome-wrap .wrap { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 !important; } + +/* ── Pill μž…λ ₯μ°½ (D4-3) ── */ +.pill-input textarea { + border-radius: 24px !important; + padding: 13px 22px !important; + resize: none !important; + border: 2px solid #e2e8f0 !important; + transition: border-color .15s !important; + line-height: 1.5 !important; +} +.pill-input textarea:focus { + border-color: #3b82f6 !important; + outline: none !important; + box-shadow: 0 0 0 3px rgba(59,130,246,.12) !important; +} +.pill-send-btn button { + border-radius: 50% !important; + width: 48px !important; + height: 48px !important; + min-width: 48px !important; + padding: 0 !important; + font-size: 1.3rem !important; + align-self: flex-end !important; + margin-bottom: 2px !important; +} + +/* ── μ˜ˆμ‹œ 질문 Chip (D4-4) ── */ +.example-chips-wrap { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0 4px; } +.example-chip { + background: #fff; + border: 1.5px solid #e2e8f0; + border-radius: 20px; + padding: 8px 16px; + font-size: .875rem; + color: #374151; + cursor: pointer; + transition: all .15s; + white-space: nowrap; +} +.example-chip:hover { + background: #eff6ff; + border-color: #93c5fd; + color: #1d4ed8; +} + +/* ── 컨트둀 νŒ¨λ„ (D4-5) ── */ +.control-row { align-items: center !important; gap: 8px !important; padding: 6px 0 !important; flex-wrap: wrap !important; } +.ctrl-check label { font-size: .83rem !important; color: #64748b !important; } +.icon-action-btn button { + border-radius: 8px !important; + padding: 6px 10px !important; + font-size: .95rem !important; + background: transparent !important; + border: 1.5px solid #e2e8f0 !important; + color: #64748b !important; + box-shadow: none !important; + min-width: 38px !important; +} +.icon-action-btn button:hover { background: #f8fafc !important; border-color: #94a3b8 !important; } +#settings-btn-wrap button { + border-radius: 8px !important; padding: 6px 10px !important; + background: transparent !important; border: 1.5px solid #e2e8f0 !important; + color: #64748b !important; box-shadow: none !important; min-width: 38px !important; +} +#control-panel { + position: absolute; bottom: 52px; right: 0; + background: #fff; border: 1px solid #e2e8f0; + border-radius: 14px; padding: 16px 18px; + box-shadow: 0 8px 28px rgba(0,0,0,.10); + z-index: 100; min-width: 220px; display: none; +} +.control-panel-wrap { position: relative; } + +/* ── μ±„νŒ… 버블 (D2-14) ── */ .message-wrap .user { background: #dbeafe !important; border-color: #93c5fd !important; @@ -385,28 +633,31 @@ footer { display: none !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; } + .youlbot-sidebar { width: 200px !important; min-width: 200px !important; max-width: 200px !important; } + .youlbot-sidebar.collapsed { width: 0 !important; min-width: 0 !important; max-width: 0 !important; padding: 0 !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; } +.dark .nav-btn button:hover { background: #1e293b !important; } +.dark .nav-btn button.nav-active { background: #1e3a5f !important; color: #93c5fd !important; } +.dark .example-chip { background: #1e293b !important; border-color: #334155 !important; color: #cbd5e1 !important; } +.dark .example-chip:hover { background: #1e3a5f !important; border-color: #3b82f6 !important; } +.dark #control-panel { background: #1e293b !important; border-color: #334155 !important; } """ _THEME = gr.themes.Soft( @@ -415,151 +666,192 @@ _THEME = gr.themes.Soft( neutral_hue="slate", ) +# ── UI ꡬ성 ─────────────────────────────────────────────────────── + with gr.Blocks(title="μœ¨λ΄‡", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo: - with gr.Row(elem_classes=["app-header"]): - gr.HTML(""" -
    - πŸ€– -
    -
    μœ¨λ΄‡
    -
    μœ‘μ•„Β·κΈˆμœ΅ μ „λ¬Έ AI 상담 λ„μš°λ―Έ
    -
    -
    -""") - gr.HTML(""" -
    - -
    -""", scale=0, min_width=60) - 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("λŒ€ν™”"): - thinking_box = gr.HTML(value="") - chatbot = gr.Chatbot(label="μœ¨λ΄‡", height=500, elem_id="main-chatbot") - source_box = gr.HTML(value="") - with gr.Row(): - msg_box = gr.Textbox( - placeholder="μ§ˆλ¬Έμ„ μž…λ ₯ν•˜μ„Έμš”... (Enter둜 전솑)", - show_label=False, - lines=2, - scale=5, - autofocus=True, - ) - send_btn = gr.Button("전솑", variant="primary", scale=1, elem_classes=["send-btn"]) - gr.Examples( - examples=[ - ["μœ‘μ•„νœ΄μ§ κΈ‰μ—¬ μ‹ μ²­ 방법을 μ•Œλ €μ£Όμ„Έμš”"], - ["어린이집 μž…μ†Œ λŒ€κΈ° 기간은 μ–Όλ§ˆλ‚˜ κ±Έλ¦¬λ‚˜μš”?"], - ["아이 μ˜λ£ŒλΉ„ μ„ΈκΈˆ κ³΅μ œλŠ” μ–΄λ–»κ²Œ ν•˜λ‚˜μš”?"], - ], - inputs=[msg_box], - label="πŸ’‘ μ˜ˆμ‹œ 질문", - ) + with gr.Row(elem_classes=["app-layout"]): - with gr.Accordion("πŸ“· 이미지 첨뢀 (선택)", open=False): - image_input = gr.Image( - type="filepath", - show_label=False, - sources=["upload", "clipboard"], - height=160, + # ── μ‚¬μ΄λ“œλ°” (D4-1) ────────────────────────────────────── + with gr.Column(elem_classes=["youlbot-sidebar"], min_width=260, scale=0): + + # λΈŒλžœλ“œ 헀더 + gr.HTML(""" +
    + + +
    +""", elem_classes=["sidebar-header-html"]) + + # λ„€λΉ„κ²Œμ΄μ…˜ λ²„νŠΌ + chat_nav_btn = gr.Button("πŸ’¬ λŒ€ν™”", elem_classes=["nav-btn"], elem_id="nav-chat", size="sm") + doc_reg_nav_btn = gr.Button("πŸ“„ λ¬Έμ„œ 등둝", elem_classes=["nav-btn"], elem_id="nav-doc-reg", size="sm") + doc_mgr_nav_btn = gr.Button("πŸ—‚ λ¬Έμ„œ 관리", elem_classes=["nav-btn"], elem_id="nav-doc-mgr", size="sm") + + gr.HTML('
    ') + + # λŒ€ν™” 이λ ₯ (D4-6) + gr.HTML(""" + +""", elem_classes=["history-section"]) + history_html = gr.HTML( + value='
    λŒ€ν™” 이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€
    ', + elem_classes=["history-section"], ) - with gr.Accordion("🎀 μŒμ„±μœΌλ‘œ μ§ˆλ¬Έν•˜κΈ°", open=False): - with gr.Row(): - audio_input = gr.Audio( - sources=["microphone"], - type="filepath", - show_label=False, - scale=4, + gr.HTML('
    ') + + # μ‚¬μš©μž 선택 + 닀크λͺ¨λ“œ + user_selector = gr.Dropdown( + choices=USER_LABELS, + value=DEFAULT_USER, + show_label=False, + elem_classes=["sidebar-user"], + ) + gr.HTML(""" +
    + +
    +""") + + # ── 메인 μ½˜ν…μΈ  ────────────────────────────────────────── + with gr.Column(elem_classes=["main-content"], scale=1): + + # ── λŒ€ν™” νŒ¨λ„ ───────────────────────────────────── + with gr.Column(visible=True, elem_id="panel-chat") as panel_chat: + + # D4-2: μ›°μ»΄ λ·° + welcome_view = gr.HTML( + value=_welcome_html(DEFAULT_USER), + elem_classes=["welcome-wrap"], ) - transcribe_btn = gr.Button("μŒμ„± β†’ ν…μŠ€νŠΈ λ³€ν™˜", scale=1) - with gr.Row(): - with gr.Column(scale=3): + thinking_box = gr.HTML(value="") + chatbot = gr.Chatbot(label="μœ¨λ΄‡", height=460, elem_id="main-chatbot") + source_box = gr.HTML(value="") + + # D4-3: Pill μž…λ ₯μ°½ + with gr.Row(elem_classes=["input-row"]): + msg_box = gr.Textbox( + placeholder="μœ¨λ΄‡μ— μ§ˆλ¬Έν•΄ λ³΄μ„Έμš”...", + show_label=False, + lines=1, + scale=5, + autofocus=True, + elem_classes=["pill-input"], + ) + send_btn = gr.Button("↑", variant="primary", scale=0, min_width=48, elem_classes=["pill-send-btn"]) + + # D4-4: μ˜ˆμ‹œ 질문 Chips + gr.HTML(_example_chips_html()) + + # 첨뢀 Accordions + with gr.Accordion("πŸ“· 이미지 첨뢀 (선택)", open=False): + image_input = gr.Image( + type="filepath", show_label=False, + sources=["upload", "clipboard"], height=160, + ) + with gr.Accordion("🎀 μŒμ„±μœΌλ‘œ μ§ˆλ¬Έν•˜κΈ°", open=False): + with gr.Row(): + audio_input = gr.Audio( + sources=["microphone"], type="filepath", + show_label=False, scale=4, + ) + transcribe_btn = gr.Button("μŒμ„± β†’ ν…μŠ€νŠΈ λ³€ν™˜", scale=1) + + # D4-5: 컨트둀 νŒ¨λ„ (μ•„μ΄μ½˜ν™”) + with gr.Row(elem_classes=["control-row"]): + show_thinking = gr.Checkbox(label="사고 κ³Όμ • ν‘œμ‹œ", value=True, elem_classes=["ctrl-check"]) + use_tts = gr.Checkbox(label="μŒμ„± λ‹΅λ³€ (TTS)", value=False, elem_classes=["ctrl-check"]) + export_btn = gr.Button("πŸ’Ύ", size="sm", elem_classes=["icon-action-btn"], min_width=38) + reset_btn = gr.Button("πŸ—‘", size="sm", elem_classes=["icon-action-btn"], min_width=38) + + export_file = gr.File(label="내보내기 파일", visible=False) + tts_output = gr.Audio(label="μŒμ„± λ‹΅λ³€", autoplay=True, visible=False) + + # ── λ¬Έμ„œ 등둝 νŒ¨λ„ ──────────────────────────────── + with gr.Column(visible=False, elem_id="panel-doc-register") as panel_doc_register: + gr.Markdown("## πŸ“„ λ¬Έμ„œ 등둝\nPDF λ˜λŠ” TXT νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λ©΄ μœ¨λ΄‡μ΄ λ‚΄μš©μ„ μ°Έκ³ ν•΄ λ‹΅λ³€ν•©λ‹ˆλ‹€.") + file_input = gr.File( + file_types=[".pdf", ".txt"], + file_count="multiple", + label="파일 선택", + ) with gr.Row(): - show_thinking = gr.Checkbox(label="사고 κ³Όμ • ν‘œμ‹œ", value=True) - use_tts = gr.Checkbox(label="μŒμ„±μœΌλ‘œ λ‹΅λ³€ 읽기 (TTS)", value=False) - with gr.Column(scale=2, min_width=240): + ingest_btn = gr.Button("λ¬Έμ„œ μˆ˜μ§‘", variant="primary", scale=0, min_width=200) + ingest_status = gr.Textbox(label="κ²°κ³Ό", interactive=False, visible=False) + + # ── λ¬Έμ„œ 관리 νŒ¨λ„ ──────────────────────────────── + with gr.Column(visible=False, elem_id="panel-doc-manage") as panel_doc_manage: + gr.Markdown("## πŸ—‚ λ¬Έμ„œ 관리\nQdrant에 λ“±λ‘λœ λ¬Έμ„œ λͺ©λ‘μž…λ‹ˆλ‹€. λΆˆν•„μš”ν•œ λ¬Έμ„œλ₯Ό μ‚­μ œν•  수 μžˆμŠ΅λ‹ˆλ‹€.") + doc_table = gr.Dataframe( + headers=["파일λͺ…", "전체 경둜"], + label="λ“±λ‘λœ λ¬Έμ„œ", + interactive=False, + ) + refresh_btn = gr.Button("μƒˆλ‘œκ³ μΉ¨") + gr.Markdown("---") with gr.Row(): - export_btn = gr.Button("πŸ’Ύ 내보내기", size="sm", min_width=100) - reset_btn = gr.Button("λŒ€ν™” μ΄ˆκΈ°ν™”", size="sm", min_width=100) + delete_source = gr.Textbox( + label="μ‚­μ œν•  파일 경둜", + placeholder="μœ„ ν‘œμ—μ„œ 행을 ν΄λ¦­ν•˜λ©΄ μžλ™μœΌλ‘œ μ±„μ›Œμ§‘λ‹ˆλ‹€", + scale=4, + ) + delete_btn = gr.Button("μ‚­μ œ", variant="stop", scale=1) + delete_status = gr.Textbox(label="κ²°κ³Ό", interactive=False) - 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]) + # μ‚¬μ΄λ“œλ°” λ„€λΉ„κ²Œμ΄μ…˜ + _panels = [panel_chat, panel_doc_register, panel_doc_manage] + chat_nav_btn.click(show_chat_panel, outputs=_panels, + js="() => setActiveNav('nav-chat')") + doc_reg_nav_btn.click(show_doc_register_panel, outputs=_panels, + js="() => setActiveNav('nav-doc-reg')") + doc_mgr_nav_btn.click(show_doc_manage_panel, outputs=_panels, + js="() => setActiveNav('nav-doc-mgr')") - user_selector.change( - switch_user, - inputs=[user_selector], - outputs=[chatbot, run_ids_state], - ).then( - lambda u: u, inputs=[user_selector], outputs=[user_state] - ) + # λŒ€ν™” + use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output]) - transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box]) + user_selector.change( + switch_user, + inputs=[user_selector], + outputs=[chatbot, run_ids_state, welcome_view], + ).then(lambda u: u, inputs=[user_selector], outputs=[user_state]) - _respond_inputs = [msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state, image_input] - _respond_outputs = [chatbot, msg_box, tts_output, run_ids_state, thinking_box, source_box, image_input] + transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box]) - 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]) + _respond_inputs = [msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state, image_input] + _respond_outputs = [chatbot, msg_box, tts_output, run_ids_state, thinking_box, source_box, image_input, welcome_view] - chatbot.like( - handle_feedback, - inputs=[chatbot, run_ids_state, user_state], - outputs=[], - ) + 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, welcome_view]) + export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file]) - with gr.Tab("λ¬Έμ„œ 등둝"): - gr.Markdown("PDF λ˜λŠ” TXT νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λ©΄ μœ¨λ΄‡μ΄ λ‚΄μš©μ„ μ°Έκ³ ν•΄ λ‹΅λ³€ν•©λ‹ˆλ‹€.") - file_input = gr.File( - file_types=[".pdf", ".txt"], - file_count="multiple", - label="파일 선택", - ) - with gr.Row(): - ingest_btn = gr.Button("λ¬Έμ„œ μˆ˜μ§‘", variant="primary", scale=0, min_width=200) - ingest_status = gr.Textbox(label="κ²°κ³Ό", interactive=False, visible=False) - ingest_btn.click(ingest_files, inputs=[file_input], outputs=[ingest_status]) + chatbot.like(handle_feedback, inputs=[chatbot, run_ids_state, user_state], outputs=[]) - with gr.Tab("λ¬Έμ„œ 관리"): - gr.Markdown("Qdrant에 λ“±λ‘λœ λ¬Έμ„œ λͺ©λ‘μž…λ‹ˆλ‹€. λΆˆν•„μš”ν•œ λ¬Έμ„œλ₯Ό μ‚­μ œν•  수 μžˆμŠ΅λ‹ˆλ‹€.") - doc_table = gr.Dataframe( - headers=["파일λͺ…", "전체 경둜"], - label="λ“±λ‘λœ λ¬Έμ„œ", - interactive=False, - ) - refresh_btn = gr.Button("μƒˆλ‘œκ³ μΉ¨") - gr.Markdown("---") - with gr.Row(): - delete_source = gr.Textbox( - label="μ‚­μ œν•  파일 경둜", - placeholder="μœ„ ν‘œμ—μ„œ 전체 경둜λ₯Ό 볡사해 λΆ™μ—¬λ„£μœΌμ„Έμš”", - scale=4, - ) - delete_btn = gr.Button("μ‚­μ œ", variant="stop", scale=1) - delete_status = gr.Textbox(label="κ²°κ³Ό", interactive=False) - - refresh_btn.click(list_docs, outputs=[doc_table]) - delete_btn.click(delete_doc, inputs=[delete_source], outputs=[delete_status, doc_table]) - doc_table.select(select_doc_row, inputs=[doc_table], outputs=[delete_source]) - demo.load(list_docs, outputs=[doc_table]) + # λ¬Έμ„œ + ingest_btn.click(ingest_files, inputs=[file_input], outputs=[ingest_status]) + refresh_btn.click(list_docs, outputs=[doc_table]) + delete_btn.click(delete_doc, inputs=[delete_source], outputs=[delete_status, doc_table]) + doc_table.select(select_doc_row, inputs=[doc_table], outputs=[delete_source]) + demo.load(list_docs, outputs=[doc_table]) if __name__ == "__main__":