Compare commits

..
13 Commits
Author SHA1 Message Date
shinalokandClaude Sonnet 4.6 51862d62ca feat: add Gemini/ChatGPT-style 3-dot bounce loading indicator
- _loading_html(): 3 blue-to-indigo gradient dots with bounce animation
- _status_html(): delegates to _loading_html for consistent style
- _live_html(): shows dots + label during thinking/meta streaming
- respond(): first yield shows _loading_html() immediately on send
- CSS: @keyframes dot-bounce (translateY -7px scale 1.05 at 30%)
- fix: move css/theme/js from gr.Blocks() to launch() (Gradio 6 API)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:34:05 +09:00
shinalokandClaude Sonnet 4.6 7f4aeb54e2 fix: suppress .generating blue border during Gradio streaming
Root cause: Gradio adds .generating class to .wrap elements during
streaming, which applies 2px solid blue border via theme CSS.

Fix: .wrap.generating { border-color: primary; border-width: 1px }
Verified: 0 blue borders on wrap elements after message send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:01:34 +09:00
shinalokandClaude Sonnet 4.6 9f1a43095e fix: remove blue focus-within border from all Gradio wraps globally
Root cause: .wrap:focus-within sets box-shadow+border-color via
--input-shadow-focus / --input-border-color-focus on ALL components.

Fix:
- .wrap:focus-within -> box-shadow:none, border-color:primary globally
- .pill-input .wrap:focus-within -> restore blue glow for input only
- .block:focus-within -> outline/shadow/border reset
- Simplify .pill-input textarea:focus to just remove outline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:28:51 +09:00
shinalokandClaude Sonnet 4.6 6c01426e37 fix: correct chip CSS selector and remove size=sm causing 26px height
- .example-chip-btn button -> button.example-chip-btn (class is on button itself)
- .example-chips-row .block for flex:1 wrapper layout
- Remove size='sm' which forced height:26px via Gradio sm class
- Add height:auto !important to allow min-height:80px to take effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:55:46 +09:00
shinalokandClaude Sonnet 4.6 dedafedcf5 fix: separate chip display label (with emoji) from actual prompt text
_EXAMPLE_CHIPS stores (label, text) pairs.
Button shows emoji label, click fills msg_box with clean text only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:52:43 +09:00
shinalokandClaude Sonnet 4.6 727c76beb4 design: increase example chip size (padding 20px, font 1rem, min-height 80px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:51:48 +09:00
shinalokandClaude Sonnet 4.6 b89bca605a fix: remove excessive blue focus borders from containers
Restrict :focus-visible outline to only button/a/select.
Remove outline from textarea/input (uses border instead).
Suppress div, .block, .wrap, #main-chatbot focus outlines.
Override Gradio default .block:focus-within box-shadow globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:48:46 +09:00
shinalokandClaude Sonnet 4.6 2d8e83581c design: upgrade example chips to card style with emoji, shadow, hover animation
- Add category emojis to question text (👶/🏫/💰)
- Card design: border-radius 14px, soft drop shadow, min-height 60px
- Equal-width flex cards (flex:1) with full-height stretch
- ::after arrow indicator (→), transitions on hover
- Hover: indigo gradient bg, purple border, translateY(-2px) lift
- Dark mode: matching dark card + purple glow on hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:46:24 +09:00
shinalokandClaude Sonnet 4.6 7decd21126 fix: align send button vertically centered with pill input
- .input-row: align-items center, gap 8px
- .pill-send-btn: align-self center on wrapper (not button)
- remove flex-end and margin-bottom that caused top misalignment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:42:47 +09:00
shinalokandClaude Sonnet 4.6 3cb89b992c fix: hide chatbot column when welcome view is visible
Wrap thinking_box/chatbot/source_box in gr.Column(visible=False).
Show on first message send, hide again on reset/user switch.
All respond yields updated to include 9th chat_column output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:40:14 +09:00
shinalokandClaude Sonnet 4.6 a456650bc8 fix: replace example chip gr.HTML+onclick with gr.Button for reliable input fill
gr.HTML inline onclick cannot update Gradio 6 Svelte component state.
Replace _example_chips_html() with gr.Button per chip wired to msg_box
via Gradio event system. CSS updated from .example-chip to .example-chip-btn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:13:36 +09:00
shinalokandClaude Sonnet 4.6 f987df5da5 UI/UX D4: Gemini-style sidebar, welcome glow, pill input, chips, icon controls, history
- 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>
2026-06-02 16:58:18 +09:00
shinalokandClaude Sonnet 4.6 1110fa14c7 docs: add D4 Gemini-style UI renewal roadmap based on design mockup
Analyzed design mockup (Gemini-style) vs current UI and documented:
- D4-1: left sidebar layout replacing top tabs
- D4-2: welcome view with personalized greeting + glow gradient
- D4-3: pill-shaped input with center/bottom position transition
- D4-4: example question chip cards (gr.HTML replacing gr.Examples)
- D4-5: control panel iconification with popup panel
- D4-6: conversation history in sidebar via localStorage

Includes comparison table, implementation difficulty ratings, and checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:10:52 +09:00
2 changed files with 702 additions and 192 deletions
+120
View File
@@ -354,6 +354,126 @@ youlbot-webui/
--- ---
### D4 — 시안 기반 UI 리뉴얼 (Gemini 스타일)
> 참고 시안: Google Gemini 스타일 — 좌측 사이드바 + 중앙 웰컴 화면 + 글로우 그라디언트
#### 시안 vs 현재 비교
| 항목 | 현재 율봇 | 시안 (Gemini 스타일) |
|------|----------|---------------------|
| 네비게이션 | 상단 수평 탭 3개 | 좌측 접이식 사이드바 |
| 빈 화면 | 빈 흰 공간 + 예시 질문 하단 | 중앙 개인화 인사 + 글로우 배경 |
| 입력창 위치 | 항상 하단 고정 | 빈 상태: 중앙 / 대화 중: 하단 |
| 입력창 모양 | 직사각형 | Pill 형태(완전 둥근 테두리) |
| 예시 질문 | 레이블 있는 리스트 | 레이블 없는 수평 chip 카드 |
| 컨트롤 위치 | 항상 노출(체크박스·버튼 3개) | 아이콘 버튼으로 숨김 처리 |
| 사용자 표시 | 드롭다운(헤더 우측) | 사이드바 하단 프로필 영역 |
| 대화 이력 | 없음 | 사이드바에 최근 대화 목록 |
#### D4-1 — 좌측 사이드바 레이아웃
> 현재 탭 구조를 사이드바로 전환하는 핵심 레이아웃 변경
- **구조**: `gr.Blocks` 내에 `gr.Column(scale=1)` 사이드바 + `gr.Column(scale=5)` 메인 영역
- **사이드바 구성**:
- 상단: 율봇 로고 + 사이드바 접기 버튼(☰)
- 중간: 네비게이션 (`💬 대화`, `📄 문서 등록`, `🗂 문서 관리`)
- 하단: 사용자 프로필(아이콘 + 이름)
- **사이드바 접기**: JS로 `sidebar-collapsed` CSS 클래스 토글 → `max-width: 0` 전환
- **현재 탭** → `gr.Tab` 제거, 메인 영역을 `gr.Column(visible=...)` 3개로 교체
- **CSS**: `.sidebar { width: 260px; transition: width .25s; }` + `.sidebar-collapsed { width: 64px; }`
#### D4-2 — 빈 화면 웰컴 뷰 (중앙 인사 + 글로우)
> 대화가 없을 때 Gemini 스타일 중앙 화면 표시
- **개인화 인사**: `"{user_name}님, 시작해 볼까요?"` — 사용자 선택값 반영
- **글로우 그라디언트**: 화면 중앙에 파란/하늘색 방사형 그라디언트 광원 효과
```css
.welcome-glow {
background: radial-gradient(ellipse 60% 50% at 50% 40%,
rgba(99,179,237,.25) 0%, rgba(147,197,253,.12) 40%, transparent 70%);
}
```
- **전환 조건**: `chatbot` 히스토리가 비어있으면 웰컴 뷰 표시, 첫 메시지 전송 시 채팅 뷰로 전환
- **구현**: `gr.HTML(elem_id="welcome-view")` + JS로 `chatbot` 내 메시지 수 감지 → visibility 토글
#### D4-3 — Pill 형태 입력창 + 위치 전환
> 입력창을 완전 둥근 pill 스타일로 변경하고 빈 상태에서는 화면 중앙에 배치
- **Pill CSS**:
```css
.pill-input textarea { border-radius: 999px !important; padding: 14px 24px !important; }
.pill-input { border-radius: 999px !important; }
```
- **중앙 배치 (빈 상태)**: 웰컴 뷰 안에 별도 입력 컨테이너 배치
- `position: relative; max-width: 680px; margin: 0 auto;`
- **아이콘 통합**: 입력창 좌측에 `+` (첨부), 우측에 전송 화살표 아이콘
- **전환**: 첫 전송 후 하단 고정 입력창으로 자연스럽게 전환 (CSS transition)
#### D4-4 — 예시 질문 Chip 카드 스타일
> 현재 리스트형 예시 질문을 시안의 수평 카드 chip으로 교체
- **현재**: `gr.Examples` 컴포넌트 (세로 리스트, 레이블 있음)
- **변경**: `gr.HTML`로 직접 렌더링한 수평 chip 카드
```html
<div class="example-chips">
<button class="chip" onclick="fillInput('육아휴직 급여 신청 방법을 알려주세요')">
육아휴직 급여 신청 방법
</button>
...
</div>
```
- **CSS**: `border-radius: 20px; border: 1px solid #e2e8f0; padding: 10px 18px; hover: background #f1f5f9`
- **JS**: `fillInput(text)` → Gradio `gr_interface_input_0` textarea에 값 설정 후 포커스
#### D4-5 — 컨트롤 패널 아이콘화
> 항상 노출된 체크박스·버튼들을 아이콘 버튼으로 숨기고 필요 시 팝업
- **현재 노출 컨트롤**: 사고 과정 표시, TTS, 내보내기, 대화 초기화
- **변경**: 입력창 우측에 `` 아이콘 → 클릭 시 컨트롤 패널 팝업(드롭업)
```css
.control-panel { position: absolute; bottom: 60px; right: 0;
background: #fff; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.12); }
```
- **항상 보이는 것**: 전송 버튼, 첨부 아이콘, 음성 아이콘
- **숨기는 것**: 사고 과정 토글, TTS 토글, 내보내기, 초기화
#### D4-6 — 대화 이력 사이드바
> 사이드바에 최근 대화 목록 표시 (시안의 좌측 채팅 이력 섹션)
- **구현**: `localStorage`에 대화 세션 저장 (최대 20개)
- **표시**: 사이드바 중간 영역에 최근 대화 제목(첫 질문 앞 20자) 리스트
- **클릭**: 해당 세션 복원 (현재는 메모리 기반이라 제목 표시만으로 시작)
- **세션 저장**: `respond` 함수 완료 시 `localStorage` 업데이트 (JS)
#### D4 구현 난이도 및 순서
| 우선순위 | 항목 | 난이도 | 비고 |
|---------|------|--------|------|
| 1순위 | D4-2 웰컴 뷰 + 글로우 | ★★☆ | CSS + JS만으로 가능 |
| 2순위 | D4-3 Pill 입력창 | ★★☆ | CSS 위주, 중앙 배치는 JS |
| 3순위 | D4-4 Chip 예시 질문 | ★★☆ | `gr.Examples` → `gr.HTML` 교체 |
| 4순위 | D4-5 컨트롤 아이콘화 | ★★★ | CSS + JS 팝업 구현 |
| 5순위 | D4-1 사이드바 레이아웃 | ★★★★ | Gradio 레이아웃 대규모 변경 |
| 6순위 | D4-6 대화 이력 | ★★★★ | 세션 관리 + localStorage |
#### D4 체크리스트
- [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개
---
## 진행 체크리스트 ## 진행 체크리스트
### P0 ### P0
+582 -192
View File
@@ -30,7 +30,7 @@ container = Container()
USER_LABELS = ["아록", "근혜", "도율", "하율"] USER_LABELS = ["아록", "근혜", "도율", "하율"]
DEFAULT_USER = "아록" DEFAULT_USER = "아록"
# ── STT (Whisper) — 로컬 실행 유지 ────────────────────────────── # ── STT (Whisper) ─────────────────────────────────────────────────
_whisper_model = None _whisper_model = None
@@ -50,11 +50,11 @@ def transcribe_audio(filepath: str) -> str:
return result["text"].strip() return result["text"].strip()
# ── 채팅 ───────────────────────────────────────────────────────── # ── 채팅 ─────────────────────────────────────────────────────────
async def respond(message, history, show_thinking, user_id, use_tts, run_ids, image_path): async def respond(message, history, show_thinking, user_id, use_tts, run_ids, image_path):
if not message.strip() and not image_path: if not message.strip() and not image_path:
yield history, "", None, run_ids, "", "", None yield history, "", None, run_ids, "", "", None, gr.update(), gr.update()
return return
history = list(history) 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 "🖼️ [이미지 첨부]" display_msg = f"🖼️ [이미지 첨부]\n{message}" if message.strip() else "🖼️ [이미지 첨부]"
history.append({"role": "user", "content": display_msg}) history.append({"role": "user", "content": display_msg})
history.append({"role": "assistant", "content": ""}) history.append({"role": "assistant", "content": ""})
yield history, "", None, run_ids, "", "", None # boxes 초기화 + 이미지 초기화 # 첫 메시지 → 웰컴 뷰 숨기고 챗봇 컬럼 표시 + 로딩 인디케이터
yield history, "", None, run_ids, _loading_html(), "", None, gr.update(visible=False), gr.update(visible=True)
collected_run_id: str | None = None collected_run_id = None
tts_text = "" tts_text = ""
thinking_acc = "" thinking_acc = ""
thinking_text = "" thinking_text = ""
thinking_finalized = False thinking_finalized = False
source_box_html = ""
try: try:
async for token, run_id in container.chat_service().chat( 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 collected_run_id = run_id
break break
# 즉시 상태 — thinking_acc에 누적 안 함
if isinstance(token, dict) and "__status" in token: if isinstance(token, dict) and "__status" in token:
if not thinking_acc: 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(), gr.update()
continue continue
# 사고 과정(LLM thinking) — 현재 줄만 live_html로 표시
if isinstance(token, dict) and "__thinking" in token: if isinstance(token, dict) and "__thinking" in token:
thinking_text += token["__thinking"] thinking_text += token["__thinking"]
thinking_acc += 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(), gr.update()
continue continue
# 진행 로그(LangGraph, 검색 등) — 메시지 전체를 live_html로 표시
if isinstance(token, dict) and "__meta" in token: if isinstance(token, dict) and "__meta" in token:
thinking_acc += token["__meta"] thinking_acc += token["__meta"]
live = token["__meta"].strip() live = token["__meta"].strip()
if live: 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(), gr.update()
continue continue
# RAG 출처 — 별도 source_box로 표시
if isinstance(token, dict) and "__sources" in token: if isinstance(token, dict) and "__sources" in token:
source_box_html = _sources_html(token["__sources"]) yield history, "", None, run_ids, gr.update(), _sources_html(token["__sources"]), gr.update(), gr.update(), gr.update()
yield history, "", None, run_ids, gr.update(), source_box_html, gr.update()
continue continue
# 첫 답변 토큰 도착 — 전체를 details로 전환 (접힌 상태)
if thinking_acc and not thinking_finalized: if thinking_acc and not thinking_finalized:
thinking_finalized = True 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(), gr.update()
tts_text += token tts_text += token
history[-1]["content"] += 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(), gr.update()
except Exception as e: except Exception as e:
history[-1]["content"] += f"\n\n[오류: {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(), gr.update()
return return
run_ids.append(collected_run_id) run_ids.append(collected_run_id)
if use_tts: if use_tts:
audio_path = await container.tts_service().speak(tts_text) 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(), gr.update()
else: 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(), gr.update()
async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): 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 return
asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant") 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 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 "" user_msg = str(history[idx - 1]["content"]) if idx > 0 else ""
asst_msg = str(history[idx]["content"]) asst_msg = str(history[idx]["content"])
rating = 1 if like_data.liked else -1 rating = 1 if like_data.liked else -1
try: try:
await container.chat_service().save_feedback(user_id, user_msg, asst_msg, rating, run_id) await container.chat_service().save_feedback(user_id, user_msg, asst_msg, rating, run_id)
except Exception as e: 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): def switch_user(user_id):
return [], [] return [], [], gr.update(value=_welcome_html(user_id), visible=True), gr.update(visible=False)
async def reset_chat(user_id): async def reset_chat(user_id):
@@ -161,7 +153,7 @@ async def reset_chat(user_id):
await container.chat_service().reset(user_id) await container.chat_service().reset(user_id)
except Exception as e: except Exception as e:
logger.error("대화 초기화 실패: %s", e) logger.error("대화 초기화 실패: %s", e)
return [], [] return [], [], gr.update(visible=True), gr.update(visible=False)
async def export_chat(history): async def export_chat(history):
@@ -227,7 +219,19 @@ async def delete_doc(source):
return f"오류: {e}", await list_docs() 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 = ( _BOX_STYLE = (
"background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;" "background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;"
@@ -240,23 +244,38 @@ _CONTENT_STYLE = (
def _last_line(text: str) -> str: def _last_line(text: str) -> str:
"""현재 진행 중인 마지막 비어있지 않은 줄 반환."""
lines = [l for l in text.split("\n") if l.strip()] lines = [l for l in text.split("\n") if l.strip()]
return lines[-1] if lines else text.strip() return lines[-1] if lines else text.strip()
def _loading_html(label: str = "율봇이 생각하고 있어요") -> str:
"""Gemini/ChatGPT 스타일 3-dot 바운스 로딩 인디케이터"""
return (
f'<div style="display:flex;align-items:center;gap:12px;'
f'padding:14px 18px;background:#f8fafc;border-radius:14px;margin-bottom:8px;">'
f'<div class="loading-dots">'
f'<span class="dot"></span><span class="dot"></span><span class="dot"></span>'
f'</div>'
f'<span style="font-size:.88rem;color:#64748b;">{_html.escape(label)}</span>'
f'</div>'
)
def _live_html(text: str) -> str: def _live_html(text: str) -> str:
"""스트리밍 중 현재 줄만 보여주는 단순 div (details 미사용 → 닫힘 현상 없음)."""
return ( return (
f'<div style="{_BOX_STYLE}">' f'<div style="{_BOX_STYLE}">'
f'<strong class="streaming-indicator">⏳ 분석 중...</strong>' f'<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)} ▌</div>' f'<div class="loading-dots">'
f'<span class="dot"></span><span class="dot"></span><span class="dot"></span>'
f'</div>'
f'<strong style="font-size:.88rem;color:#64748b;">분석 중...</strong>'
f'</div>'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)}</div>'
f'</div>' f'</div>'
) )
def _thinking_html(text: str) -> str: def _thinking_html(text: str) -> str:
"""완료 후 전체 내용을 접기/펼치기로 표시."""
return ( return (
f'<details style="{_BOX_STYLE}">' f'<details style="{_BOX_STYLE}">'
f'<summary style="cursor:pointer;font-weight:bold;">💭 분석 완료</summary>' f'<summary style="cursor:pointer;font-weight:bold;">💭 분석 완료</summary>'
@@ -266,16 +285,10 @@ def _thinking_html(text: str) -> str:
def _status_html(status: str) -> str: def _status_html(status: str) -> str:
"""내용 없이 상태만 표시하는 단순 헤더.""" return _loading_html(status)
return (
f'<div style="{_BOX_STYLE}">'
f'<strong>🤔 {_html.escape(status)}</strong>'
f'</div>'
)
def _sources_html(sources: list) -> str: def _sources_html(sources: list) -> str:
"""RAG 출처 목록을 접기/펼치기로 표시."""
items = "".join( items = "".join(
f"<li>{_html.escape(s['filename'])}" f"<li>{_html.escape(s['filename'])}"
+ (f"{s['page']}페이지" if "page" in s else "") + (f"{s['page']}페이지" if "page" in s else "")
@@ -290,6 +303,39 @@ def _sources_html(sources: list) -> str:
) )
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_CHIPS = [
("👶 육아휴직 급여 신청 방법을 알려주세요", "육아휴직 급여 신청 방법을 알려주세요"),
("🏫 어린이집 입소 대기 기간은 얼마나 걸리나요?", "어린이집 입소 대기 기간은 얼마나 걸리나요?"),
("💰 아이 의료비 세금 공제는 어떻게 하나요?", "아이 의료비 세금 공제는 어떻게 하나요?"),
] # (버튼 표시 텍스트, 실제 전송 텍스트)
# ── JavaScript (D3 + D4) ──────────────────────────────────────────
_JS = """ _JS = """
() => { () => {
const htmlEl = document.documentElement; const htmlEl = document.documentElement;
@@ -307,16 +353,80 @@ _JS = """
htmlEl.classList.add('dark'); 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: 입력창 포커스 유틸
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
? '<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() { function addAriaLabels() {
const ta = document.querySelector('textarea[placeholder*="질문"]'); const ta = document.querySelector('.pill-input textarea');
if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)'); if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)');
document.querySelectorAll('.send-btn').forEach(b => b.setAttribute('aria-label', '메시지 전송'));
const cb = document.getElementById('main-chatbot'); 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() { function showOnboarding() {
if (localStorage.getItem('youlbot_onboarded')) return; if (localStorage.getItem('youlbot_onboarded')) return;
const modal = document.createElement('div'); const modal = document.createElement('div');
@@ -345,35 +455,239 @@ _JS = """
}; };
setTimeout(function() { 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(); addAriaLabels();
showOnboarding(); showOnboarding();
const btn = document.getElementById('dark-mode-btn'); window.renderChatHistory();
if (btn && htmlEl.classList.contains('dark')) btn.textContent = '☀️';
}, 1500); }, 1500);
} }
""" """
# ── CSS ───────────────────────────────────────────────────────────
_CUSTOM_CSS = """ _CUSTOM_CSS = """
footer { display: none !important; } footer { display: none !important; }
/* 입력 영역 */ /* ── 로딩 인디케이터 (Gemini/ChatGPT 3-dot 바운스) ── */
.send-btn { min-height: 80px !important; align-self: stretch !important; } .loading-dots { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; }
.loading-dots .dot {
/* 헤더 (D2-11) */ width: 8px; height: 8px; border-radius: 50%;
.app-header { background: linear-gradient(135deg, #3b82f6 0%, #818cf8 100%);
align-items: center !important; animation: dot-bounce 1.4s ease-in-out infinite;
padding-bottom: 12px !important;
border-bottom: 1px solid var(--border-color-primary);
margin-bottom: 4px !important;
} }
.app-header > .wrap, .app-header > div > .wrap { .loading-dots .dot:nth-child(2) { animation-delay: .18s; }
padding: 0 !important; .loading-dots .dot:nth-child(3) { animation-delay: .36s; }
@keyframes dot-bounce {
0%, 60%, 100% { transform: translateY(0) scale(0.65); opacity: 0.35; }
30% { transform: translateY(-7px) scale(1.05); opacity: 1; }
}
/* 스트리밍 중 파란 테두리 제거 (.generating 클래스가 원인) */
.wrap.generating {
border-color: var(--border-color-primary) !important;
border-width: 1px !important;
}
/* focus-within 전역 제거 */
.wrap:focus-within {
box-shadow: none !important;
border-color: var(--border-color-primary) !important;
}
/* pill-input에만 포커스 스타일 복원 */
.pill-input .wrap:focus-within {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59,130,246,.10) !important;
}
/* block 레벨 focus 제거 */
.gradio-container .block:focus-within,
.gradio-container .block:focus {
box-shadow: none !important;
border-color: var(--border-color-primary) !important;
outline: 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; background: transparent !important;
border: none !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; 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 {
outline: none !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;
}
/* 버튼 wrapper(.block)에 flex:1 적용 */
.example-chips-row .block {
flex: 1 !important;
min-width: 0 !important;
}
/* 버튼 자체에 셀렉터 적용 (elem_classes가 button에 직접 붙음) */
button.example-chip-btn {
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) !important;
white-space: normal !important;
text-align: left !important;
width: 100% !important;
height: auto !important;
min-height: 80px !important;
justify-content: flex-start !important;
transition: all .2s ease !important;
}
button.example-chip-btn::after {
content: '';
color: #94a3b8;
font-size: .85rem;
margin-left: 6px;
}
button.example-chip-btn: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 { .message-wrap .user {
background: #dbeafe !important; background: #dbeafe !important;
border-color: #93c5fd !important; border-color: #93c5fd !important;
@@ -385,28 +699,51 @@ footer { display: none !important; }
border-bottom-left-radius: 4px !important; border-bottom-left-radius: 4px !important;
} }
/* 응답 스트리밍 애니메이션 (D2-15) */ /* ── 스트리밍 애니메이션 ── */
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.streaming-indicator { animation: blink 1.2s ease-in-out infinite; display: inline-block; } .streaming-indicator { animation: blink 1.2s ease-in-out infinite; display: inline-block; }
/* 반응형 (D2-16) */ /* ── 반응형 ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.send-btn { min-height: 56px !important; } .youlbot-sidebar { width: 200px !important; min-width: 200px !important; max-width: 200px !important; }
.app-header { flex-wrap: wrap; gap: 8px; } .youlbot-sidebar.collapsed { width: 0 !important; min-width: 0 !important; max-width: 0 !important; padding: 0 !important; }
.message-wrap .user, .message-wrap .bot { max-width: 92% !important; }
} }
/* 접근성: 포커스 표시 강화 (D3-19) */ /* ── 접근성: 포커스 테두리를 인터랙티브 요소에만 적용 ── */
:focus-visible { button:focus-visible,
outline: 3px solid #3b82f6 !important; a:focus-visible,
select:focus-visible {
outline: 2px solid #3b82f6 !important;
outline-offset: 2px !important; outline-offset: 2px !important;
border-radius: 4px; 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;
} }
/* 다크 모드 커스텀 요소 오버라이드 (D3-17) */ /* ── 다크 모드 오버라이드 ── */
.dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; } .dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; }
.dark .message-wrap .bot { background: #1e293b !important; border-color: #334155 !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 button.example-chip-btn { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; box-shadow: 0 1px 6px rgba(0,0,0,.3) !important; }
.dark button.example-chip-btn: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( _THEME = gr.themes.Soft(
@@ -415,155 +752,208 @@ _THEME = gr.themes.Soft(
neutral_hue="slate", neutral_hue="slate",
) )
with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo: # ── UI 구성 ───────────────────────────────────────────────────────
with gr.Row(elem_classes=["app-header"]):
gr.HTML(""" with gr.Blocks(title="율봇") as demo:
<div style="display:flex;align-items:center;gap:14px;padding:4px 0;">
<span style="font-size:2.2rem;line-height:1;">🤖</span>
<div>
<div style="font-size:1.6rem;font-weight:700;color:#1e293b;line-height:1.15;">율봇</div>
<div style="font-size:0.85rem;color:#64748b;margin-top:3px;">육아·금융 전문 AI 상담 도우미</div>
</div>
</div>
""")
gr.HTML("""
<div style="display:flex;justify-content:flex-end;align-items:center;height:100%;">
<button id="dark-mode-btn" onclick="toggleDarkMode()" title="다크/라이트 모드 전환"
style="background:none;border:1.5px solid #e2e8f0;border-radius:8px;cursor:pointer;
font-size:1.2rem;padding:6px 10px;line-height:1;color:#64748b;transition:all .15s;"
onmouseover="this.style.borderColor='#94a3b8'"
onmouseout="this.style.borderColor='#e2e8f0'">🌙</button>
</div>
""", scale=0, min_width=60)
user_selector = gr.Dropdown(
choices=USER_LABELS,
value=DEFAULT_USER,
label="사용자",
scale=0,
min_width=160,
)
user_state = gr.State(DEFAULT_USER) user_state = gr.State(DEFAULT_USER)
run_ids_state = gr.State([]) run_ids_state = gr.State([])
with gr.Tab("대화"): with gr.Row(elem_classes=["app-layout"]):
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.Accordion("📷 이미지 첨부 (선택)", open=False): # ── 사이드바 (D4-1) ──────────────────────────────────────
image_input = gr.Image( with gr.Column(elem_classes=["youlbot-sidebar"], min_width=260, scale=0):
type="filepath",
show_label=False, # 브랜드 헤더
sources=["upload", "clipboard"], gr.HTML("""
height=160, <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"],
) )
with gr.Accordion("🎤 음성으로 질문하기", open=False): gr.HTML('<div style="min-height:16px;"></div>')
with gr.Row():
audio_input = gr.Audio( # 사용자 선택 + 다크모드
sources=["microphone"], user_selector = gr.Dropdown(
type="filepath", choices=USER_LABELS,
show_label=False, value=DEFAULT_USER,
scale=4, 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"],
) )
transcribe_btn = gr.Button("음성 → 텍스트 변환", scale=1)
with gr.Row(): with gr.Column(visible=False) as chat_column:
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 — 버튼 label은 이모지 포함, 전송값은 텍스트만
with gr.Row(elem_classes=["example-chips-row"]):
chip_btns = [
gr.Button(label, elem_classes=["example-chip-btn"])
for label, _ in _EXAMPLE_CHIPS
]
# 첨부 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(): with gr.Row():
show_thinking = gr.Checkbox(label="사고 과정 표시", value=True) ingest_btn = gr.Button("문서 수집", variant="primary", scale=0, min_width=200)
use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False) ingest_status = gr.Textbox(label="결과", interactive=False, visible=False)
with gr.Column(scale=2, min_width=240):
# ── 문서 관리 패널 ────────────────────────────────
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(): with gr.Row():
export_btn = gr.Button("💾 내보내기", size="sm", min_width=100) delete_source = gr.Textbox(
reset_btn = gr.Button("대화 초기화", size="sm", min_width=100) 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) # 예시 질문 chips → msg_box 채우기 (이모지 제외한 텍스트만)
use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output]) for (_, _text), _btn in zip(_EXAMPLE_CHIPS, chip_btns):
_btn.click(fn=lambda x=_text: x, outputs=[msg_box])
user_selector.change( # 사이드바 네비게이션
switch_user, _panels = [panel_chat, panel_doc_register, panel_doc_manage]
inputs=[user_selector], chat_nav_btn.click(show_chat_panel, outputs=_panels,
outputs=[chatbot, run_ids_state], js="() => setActiveNav('nav-chat')")
).then( doc_reg_nav_btn.click(show_doc_register_panel, outputs=_panels,
lambda u: u, inputs=[user_selector], outputs=[user_state] js="() => setActiveNav('nav-doc-reg')")
) doc_mgr_nav_btn.click(show_doc_manage_panel, outputs=_panels,
js="() => setActiveNav('nav-doc-mgr')")
transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box]) # 대화
use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output])
_respond_inputs = [msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state, image_input] user_selector.change(
_respond_outputs = [chatbot, msg_box, tts_output, run_ids_state, thinking_box, source_box, image_input] switch_user,
inputs=[user_selector],
outputs=[chatbot, run_ids_state, welcome_view, chat_column],
).then(lambda u: u, inputs=[user_selector], outputs=[user_state])
send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs) transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box])
msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs)
reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state])
export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file])
chatbot.like( _respond_inputs = [msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state, image_input]
handle_feedback, _respond_outputs = [chatbot, msg_box, tts_output, run_ids_state, thinking_box, source_box, image_input, welcome_view, chat_column]
inputs=[chatbot, run_ids_state, user_state],
outputs=[],
)
with gr.Tab("문서 등록"): send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs)
gr.Markdown("PDF 또는 TXT 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.") msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs)
file_input = gr.File( reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state, welcome_view, chat_column])
file_types=[".pdf", ".txt"], export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file])
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])
with gr.Tab("문서 관리"): chatbot.like(handle_feedback, inputs=[chatbot, run_ids_state, user_state], outputs=[])
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]) ingest_btn.click(ingest_files, inputs=[file_input], outputs=[ingest_status])
doc_table.select(select_doc_row, inputs=[doc_table], outputs=[delete_source]) refresh_btn.click(list_docs, outputs=[doc_table])
demo.load(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__": if __name__ == "__main__":
demo.launch( demo.launch(
server_name=container.config().server_host, server_name=container.config().server_host,
server_port=container.config().server_port, server_port=container.config().server_port,
theme=_THEME,
css=_CUSTOM_CSS,
js=_JS,
) )