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__":