- 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 <noreply@anthropic.com>
862 lines
36 KiB
Python
862 lines
36 KiB
Python
"""율봇 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()
|
|
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)
|
|
|
|
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()
|
|
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()
|
|
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()
|
|
continue
|
|
|
|
if isinstance(token, dict) and "__sources" in token:
|
|
yield history, "", None, run_ids, gr.update(), _sources_html(token["__sources"]), 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()
|
|
|
|
tts_text += token
|
|
history[-1]["content"] += token
|
|
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(), 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()
|
|
else:
|
|
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):
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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'<div style="{_BOX_STYLE}">'
|
|
f'<strong class="streaming-indicator">⏳ 분석 중...</strong>'
|
|
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)} ▌</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
|
|
def _thinking_html(text: str) -> str:
|
|
return (
|
|
f'<details style="{_BOX_STYLE}">'
|
|
f'<summary style="cursor:pointer;font-weight:bold;">💭 분석 완료</summary>'
|
|
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)}</div>'
|
|
f'</details>'
|
|
)
|
|
|
|
|
|
def _status_html(status: str) -> str:
|
|
return (
|
|
f'<div style="{_BOX_STYLE}">'
|
|
f'<strong>🤔 {_html.escape(status)}</strong>'
|
|
f'</div>'
|
|
)
|
|
|
|
|
|
def _sources_html(sources: list) -> str:
|
|
items = "".join(
|
|
f"<li>{_html.escape(s['filename'])}"
|
|
+ (f" — {s['page']}페이지" if "page" in s else "")
|
|
+ "</li>"
|
|
for s in sources
|
|
)
|
|
return (
|
|
f'<details style="{_BOX_STYLE}">'
|
|
f'<summary style="cursor:pointer;font-weight:bold;">📄 출처 ({len(sources)}개)</summary>'
|
|
f'<ul style="margin:6px 0;padding-left:18px;font-size:0.85em;color:#555;">{items}</ul>'
|
|
f'</details>'
|
|
)
|
|
|
|
|
|
def _welcome_html(user_name: str) -> str:
|
|
"""D4-2: 개인화 웰컴 뷰 — 글로우 그라디언트 + 인사말"""
|
|
return f"""
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
min-height:380px;padding:48px 24px 24px;text-align:center;position:relative;overflow:hidden;">
|
|
<div style="position:absolute;inset:0;
|
|
background:radial-gradient(ellipse 70% 55% at 50% 42%,
|
|
rgba(99,179,237,.22) 0%, rgba(147,197,253,.10) 45%, transparent 70%);
|
|
pointer-events:none;"></div>
|
|
<div style="position:relative;z-index:1;">
|
|
<div style="font-size:3rem;margin-bottom:18px;">🤖</div>
|
|
<h1 style="font-size:1.85rem;font-weight:700;color:#1e293b;margin:0 0 10px;letter-spacing:-.02em;line-height:1.2;">
|
|
{_html.escape(user_name)}님, 시작해 볼까요?
|
|
</h1>
|
|
<p style="font-size:0.95rem;color:#64748b;margin:0;">
|
|
육아·금융 전문 AI 상담 도우미입니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
_EXAMPLE_QUESTIONS = [
|
|
"육아휴직 급여 신청 방법을 알려주세요",
|
|
"어린이집 입소 대기 기간은 얼마나 걸리나요?",
|
|
"아이 의료비 세금 공제는 어떻게 하나요?",
|
|
]
|
|
|
|
|
|
def _example_chips_html() -> str:
|
|
"""D4-4: 예시 질문 chip 카드"""
|
|
chips = "".join(
|
|
f'<button class="example-chip" onclick="fillInput({repr(q)})">{_html.escape(q)}</button>'
|
|
for q in _EXAMPLE_QUESTIONS
|
|
)
|
|
return f'<div id="example-chips" class="example-chips-wrap">{chips}</div>'
|
|
|
|
|
|
# ── 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/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
|
|
? '<div style="font-size:.8rem;color:#94a3b8;padding:8px 4px;">대화 이력이 없습니다</div>'
|
|
: history.map(h =>
|
|
`<div class="history-item" title="${h.title}">
|
|
<span style="flex-shrink:0">💬</span>
|
|
<span class="history-title">${h.title}</span>
|
|
</div>`
|
|
).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 = `
|
|
<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() {
|
|
// 사이드바 상태 복원
|
|
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; }
|
|
|
|
/* ── 전체 레이아웃 (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 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;
|
|
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; }
|
|
}
|
|
|
|
/* ── 접근성 ── */
|
|
:focus-visible {
|
|
outline: 3px solid #3b82f6 !important;
|
|
outline-offset: 2px !important;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* ── 다크 모드 오버라이드 ── */
|
|
.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 { 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(
|
|
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("""
|
|
<div style="display:flex;align-items:center;gap:10px;padding:4px 2px 12px;">
|
|
<button onclick="toggleSidebar()" title="사이드바 접기"
|
|
style="background:none;border:none;cursor:pointer;font-size:1.1rem;padding:4px;
|
|
color:#64748b;border-radius:6px;flex-shrink:0;">☰</button>
|
|
<div class="sidebar-brand-text" style="display:flex;align-items:center;gap:8px;overflow:hidden;">
|
|
<span style="font-size:1.4rem;flex-shrink:0;">🤖</span>
|
|
<div>
|
|
<div style="font-size:1rem;font-weight:700;color:#1e293b;white-space:nowrap;">율봇</div>
|
|
<div style="font-size:.72rem;color:#94a3b8;white-space:nowrap;">육아·금융 AI</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""", 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('<div style="flex:1;min-height:16px;"></div>')
|
|
|
|
# 대화 이력 (D4-6)
|
|
gr.HTML("""
|
|
<div class="history-section sidebar-label" style="font-size:.75rem;font-weight:600;color:#94a3b8;
|
|
letter-spacing:.06em;text-transform:uppercase;padding:8px 4px 4px;">최근 대화</div>
|
|
""", elem_classes=["history-section"])
|
|
history_html = gr.HTML(
|
|
value='<div id="chat-history-list" style="font-size:.8rem;color:#94a3b8;padding:6px 4px;">대화 이력이 없습니다</div>',
|
|
elem_classes=["history-section"],
|
|
)
|
|
|
|
gr.HTML('<div style="min-height:16px;"></div>')
|
|
|
|
# 사용자 선택 + 다크모드
|
|
user_selector = gr.Dropdown(
|
|
choices=USER_LABELS,
|
|
value=DEFAULT_USER,
|
|
show_label=False,
|
|
elem_classes=["sidebar-user"],
|
|
)
|
|
gr.HTML("""
|
|
<div style="display:flex;justify-content:flex-end;padding:4px 2px 0;">
|
|
<button id="dark-mode-btn" onclick="toggleDarkMode()" title="다크/라이트 모드"
|
|
style="background:none;border:1.5px solid #e2e8f0;border-radius:8px;cursor:pointer;
|
|
font-size:1.1rem;padding:5px 9px;color:#64748b;transition:all .15s;">🌙</button>
|
|
</div>
|
|
""")
|
|
|
|
# ── 메인 콘텐츠 ──────────────────────────────────────────
|
|
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"],
|
|
)
|
|
|
|
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():
|
|
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)
|
|
|
|
# ── 이벤트 바인딩 ─────────────────────────────────────────────
|
|
|
|
# 사이드바 네비게이션
|
|
_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],
|
|
).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]
|
|
|
|
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])
|
|
|
|
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,
|
|
)
|