"""율봇 WebUI — youlbot REST API를 호출하는 Gradio 프론트엔드. 실행: python app.py 환경변수 (.env): YOULBOT_API_URL=http://localhost:8000 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 """ import html as _html import logging import os import gradio as gr from dotenv import load_dotenv load_dotenv() logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-8s %(name)s — %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) from container import Container container = Container() USER_LABELS = ["아록", "근혜", "도율", "하율"] DEFAULT_USER = "아록" # ── STT (Whisper) ───────────────────────────────────────────────── _whisper_model = None def _get_whisper(): global _whisper_model if _whisper_model is None: import whisper _whisper_model = whisper.load_model(container.config().whisper_model_size) return _whisper_model def transcribe_audio(filepath: str) -> str: if not filepath: return "" model = _get_whisper() result = model.transcribe(filepath, language="ko") 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, gr.update(), gr.update() return history = list(history) run_ids = list(run_ids) display_msg = message if image_path: display_msg = f"🖼️ [이미지 첨부]\n{message}" if message.strip() else "🖼️ [이미지 첨부]" history.append({"role": "user", "content": display_msg}) history.append({"role": "assistant", "content": ""}) # 첫 메시지 → 웰컴 뷰 숨기고 챗봇 컬럼 표시 (D4-2) yield history, "", None, run_ids, "", "", None, gr.update(visible=False), gr.update(visible=True) collected_run_id = None tts_text = "" thinking_acc = "" thinking_text = "" thinking_finalized = False try: async for token, run_id in container.chat_service().chat( message or "이 이미지를 분석해줘.", user_id, show_thinking, image_path=image_path ): if run_id is not None: collected_run_id = run_id break 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(), gr.update(), gr.update() continue 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(), gr.update(), gr.update() continue 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(), gr.update(), gr.update() continue if isinstance(token, dict) and "__sources" in token: yield history, "", None, run_ids, gr.update(), _sources_html(token["__sources"]), gr.update(), gr.update(), gr.update() continue if thinking_acc and not thinking_finalized: thinking_finalized = True yield history, "", None, run_ids, _thinking_html(thinking_acc), gr.update(), gr.update(), gr.update(), gr.update() tts_text += token history[-1]["content"] += token yield history, "", None, run_ids, gr.update(), 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(), 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(), gr.update(), gr.update() else: yield history, "", None, run_ids, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): idx = like_data.index if isinstance(idx, (list, tuple)): idx = idx[0] if not isinstance(idx, int) or idx < 0 or idx >= len(history): return if history[idx].get("role") != "assistant": 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: logger.error("피드백 저장 실패: %s", e) def switch_user(user_id): return [], [], gr.update(value=_welcome_html(user_id), visible=True), gr.update(visible=False) async def reset_chat(user_id): try: await container.chat_service().reset(user_id) except Exception as e: logger.error("대화 초기화 실패: %s", e) return [], [], gr.update(visible=True), gr.update(visible=False) 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): if not files: return gr.update(value="파일을 선택해주세요.", visible=True) paths = [f if isinstance(f, str) else f.name for f in files] results = [] for path in paths: try: result = await container.document_service().ingest(path) name = os.path.basename(path) results.append(f"{name} → {result.get('chunks', '?')}개 청크") except Exception as e: results.append(f"{os.path.basename(path)} 오류: {e}") return gr.update(value="\n".join(results), visible=True) async def list_docs(): try: sources = await container.document_service().list_documents() return [[os.path.basename(s), s] for s in sources] except Exception as e: return [[f"오류: {e}", ""]] def select_doc_row(evt: gr.SelectData, doc_data): row = evt.index[0] try: if hasattr(doc_data, "iloc"): return str(doc_data.iloc[row, 1]) return str(doc_data[row][1]) except Exception: return gr.update() async def delete_doc(source): if not source.strip(): return "삭제할 파일 경로를 입력하세요.", await list_docs() try: await container.document_service().delete_document(source.strip()) return f"삭제 완료: {os.path.basename(source.strip())}", await list_docs() except Exception as e: return f"오류: {e}", await list_docs() # ── 패널 전환 (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;" "padding:8px 14px;margin-bottom:6px;" ) _CONTENT_STYLE = ( "margin-top:6px;white-space:pre-wrap;font-size:0.85em;" "color:#555;max-height:160px;overflow-y:auto;" ) 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: return ( f'
' f'⏳ 분석 중...' f'
{_html.escape(text)} ▌
' f'
' ) def _thinking_html(text: str) -> str: return ( f'
' f'💭 분석 완료' f'
{_html.escape(text)}
' f'
' ) def _status_html(status: str) -> str: return ( f'
' f'🤔 {_html.escape(status)}' f'
' ) def _sources_html(sources: list) -> str: items = "".join( f"
  • {_html.escape(s['filename'])}" + (f" — {s['page']}페이지" if "page" in s else "") + "
  • " for s in sources ) return ( f'
    ' f'📄 출처 ({len(sources)}개)' f'' f'
    ' ) def _welcome_html(user_name: str) -> str: """D4-2: 개인화 웰컴 뷰 — 글로우 그라디언트 + 인사말""" return f"""
    🤖

    {_html.escape(user_name)}님, 시작해 볼까요?

    육아·금융 전문 AI 상담 도우미입니다.

    """ _EXAMPLE_QUESTIONS = [ "👶 육아휴직 급여 신청 방법을 알려주세요", "🏫 어린이집 입소 대기 기간은 얼마나 걸리나요?", "💰 아이 의료비 세금 공제는 어떻게 하나요?", ] # ── JavaScript (D3 + D4) ────────────────────────────────────────── _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'); } // 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: 입력창 포커스 유틸 window.focusInput = function() { const ta = document.querySelector('.pill-input textarea'); if (ta) 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('.pill-input textarea'); if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)'); const cb = document.getElementById('main-chatbot'); if (cb) { cb.setAttribute('role', 'log'); cb.setAttribute('aria-live', 'polite'); } } // 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() { // 사이드바 상태 복원 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(); window.renderChatHistory(); }, 1500); } """ # ── CSS ─────────────────────────────────────────────────────────── _CUSTOM_CSS = """ footer { display: none !important; } /* Gradio 기본 focus 테두리 전역 제거 */ .gradio-container *:focus:not(button):not(a):not(select) { outline: none !important; } .gradio-container .block:focus, .gradio-container .block:focus-within { box-shadow: none !important; border-color: var(--border-color-primary) !important; } /* ── 전체 레이아웃 (D4-1) ── */ .app-layout { min-height: calc(100vh - 60px) !important; gap: 0 !important; align-items: stretch !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; } /* 대화 이력 (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 { align-self: center !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; } /* ── 입력 Row 세로 중앙 정렬 ── */ .input-row { align-items: center !important; gap: 8px !important; } /* ── 예시 질문 Chip 카드 (D4-4) ── */ .example-chips-row { padding: 10px 0 6px !important; flex-wrap: nowrap !important; gap: 10px !important; align-items: stretch !important; } .example-chip-btn { flex: 1 !important; min-width: 0 !important; } .example-chip-btn button { border-radius: 16px !important; border: 1.5px solid #e8edf4 !important; background: #ffffff !important; color: #1e293b !important; padding: 20px 22px !important; font-size: 1rem !important; font-weight: 400 !important; line-height: 1.5 !important; box-shadow: 0 2px 8px rgba(0,0,0,.07), 0 0 0 1px rgba(0,0,0,.02) !important; white-space: normal !important; text-align: left !important; width: 100% !important; height: 100% !important; min-height: 80px !important; justify-content: flex-start !important; transition: all .2s ease !important; } .example-chip-btn button::after { content: ' →'; color: #94a3b8; font-size: .8rem; margin-left: 4px; transition: color .2s, transform .2s; display: inline-block; } .example-chip-btn button:hover { background: linear-gradient(135deg, #f0f7ff 0%, #f5f3ff 100%) !important; border-color: #a5b4fc !important; color: #1e40af !important; box-shadow: 0 6px 20px rgba(99,102,241,.15) !important; transform: translateY(-2px) !important; } /* ── 컨트롤 패널 (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; border-bottom-right-radius: 4px !important; } .message-wrap .bot { background: #f8fafc !important; border-color: #e2e8f0 !important; border-bottom-left-radius: 4px !important; } /* ── 스트리밍 애니메이션 ── */ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } .streaming-indicator { animation: blink 1.2s ease-in-out infinite; display: inline-block; } /* ── 반응형 ── */ @media (max-width: 768px) { .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; } } /* ── 접근성: 포커스 테두리를 인터랙티브 요소에만 적용 ── */ button:focus-visible, a:focus-visible, select:focus-visible { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; border-radius: 6px; } /* textarea/input은 border로 포커스 표시하므로 outline 제거 */ textarea:focus-visible, input:focus-visible { outline: none !important; } /* 컨테이너 div에 생기는 파란 테두리 제거 */ div:focus-visible, div:focus, .block:focus, .block:focus-within, .wrap:focus, .wrap:focus-within, #main-chatbot:focus, #main-chatbot:focus-within { outline: none !important; box-shadow: none !important; border-color: transparent !important; } /* ── 다크 모드 오버라이드 ── */ .dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; } .dark .message-wrap .bot { background: #1e293b !important; 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-btn button { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; box-shadow: 0 1px 6px rgba(0,0,0,.3) !important; } .dark .example-chip-btn button:hover { background: linear-gradient(135deg, #1e3a5f, #1e1b4b) !important; border-color: #818cf8 !important; color: #c7d2fe !important; } .dark #control-panel { background: #1e293b !important; border-color: #334155 !important; } """ _THEME = gr.themes.Soft( primary_hue="blue", secondary_hue="indigo", neutral_hue="slate", ) # ── UI 구성 ─────────────────────────────────────────────────────── with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo: user_state = gr.State(DEFAULT_USER) run_ids_state = gr.State([]) with gr.Row(elem_classes=["app-layout"]): # ── 사이드바 (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"], ) 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"], ) with gr.Column(visible=False) as chat_column: 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.Button — Gradio 이벤트로 msg_box 직접 업데이트) with gr.Row(elem_classes=["example-chips-row"]): chip_btns = [ gr.Button(q, elem_classes=["example-chip-btn"], size="sm") for q in _EXAMPLE_QUESTIONS ] # 첨부 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(): 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(): delete_source = gr.Textbox( label="삭제할 파일 경로", placeholder="위 표에서 행을 클릭하면 자동으로 채워집니다", scale=4, ) delete_btn = gr.Button("삭제", variant="stop", scale=1) delete_status = gr.Textbox(label="결과", interactive=False) # ── 이벤트 바인딩 ───────────────────────────────────────────── # 예시 질문 chips → msg_box 채우기 for _q, _btn in zip(_EXAMPLE_QUESTIONS, chip_btns): _btn.click(fn=lambda x=_q: x, outputs=[msg_box]) # 사이드바 네비게이션 _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')") # 대화 use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output]) user_selector.change( switch_user, inputs=[user_selector], outputs=[chatbot, run_ids_state, welcome_view, chat_column], ).then(lambda u: u, inputs=[user_selector], outputs=[user_state]) transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box]) _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, chat_column] 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, chat_column]) export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file]) chatbot.like(handle_feedback, inputs=[chatbot, run_ids_state, user_state], outputs=[]) # 문서 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__": demo.launch( server_name=container.config().server_host, server_port=container.config().server_port, )