"""율봇 WebUI — youlbot REST API를 호출하는 Gradio 프론트엔드. 실행: python app.py 환경변수 (.env): YOULBOT_API_URL=http://localhost:8000 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 """ import asyncio import html as _html import os import platform import subprocess import tempfile import gradio as gr from dotenv import load_dotenv load_dotenv() import api_client USER_LABELS = ["아록", "근혜", "도율", "하율"] DEFAULT_USER = "아록" # ── STT (Whisper) — 로컬 실행 유지 ────────────────────────────── _whisper_model = None _WHISPER_SIZE = os.getenv("WHISPER_MODEL_SIZE", "small") _TTS_VOICE = os.getenv("TTS_VOICE", "Yuna") # macOS say 보이스 _TTS_EDGE_VOICE = os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural") # edge-tts 보이스 def _get_whisper(): global _whisper_model if _whisper_model is None: import whisper _whisper_model = whisper.load_model(_WHISPER_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 tts_speak(text: str) -> str | None: """크로스플랫폼 TTS. macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3""" if not text: return None # macOS: say 우선 (오프라인, 내장 한국어) if platform.system() == "Darwin": try: tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False) tmp.close() await asyncio.to_thread( subprocess.run, ["say", "-v", _TTS_VOICE, "-o", tmp.name, text], check=True, capture_output=True, ) return tmp.name except Exception: pass # Windows 1순위 / macOS say 실패 시: edge-tts (온라인) try: import edge_tts tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) tmp.close() await edge_tts.Communicate(text, _TTS_EDGE_VOICE).save(tmp.name) return tmp.name except Exception: pass # 최종 폴백: pyttsx3 (오프라인) try: import pyttsx3 tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.close() def _save(): engine = pyttsx3.init() engine.save_to_file(text, tmp.name) engine.runAndWait() await asyncio.to_thread(_save) return tmp.name except Exception: return None # ── 채팅 ───────────────────────────────────────────────────────── async def respond(message, history, show_thinking, user_id, use_tts, run_ids): if not message.strip(): yield history, "", None, run_ids, "" return history = list(history) run_ids = list(run_ids) history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": ""}) yield history, "", None, run_ids, "" # thinking_box 초기화 collected_run_id: str | None = None tts_text = "" # 순수 답변만 누적 (TTS용) thinking_acc = "" # 사고 과정 + 진행 로그 누적 thinking_finalized = False # 첫 답변 토큰 도착 시 박스 완료 처리 try: async for token, run_id in api_client.chat(message, user_id, show_thinking): if run_id is not None: collected_run_id = run_id break # 사고 과정(LLM thinking) — 박스에 추가 if isinstance(token, dict) and "__thinking" in token: thinking_acc += token["__thinking"] yield history, "", None, run_ids, _thinking_html(thinking_acc) continue # 진행 로그(LangGraph, 검색 등) — 박스에 추가 (챗봇에는 표시 안 함) if isinstance(token, dict) and "__meta" in token: thinking_acc += token["__meta"] yield history, "", None, run_ids, _thinking_html(thinking_acc) continue # 첫 답변 토큰 도착 — 박스를 완료 상태로 전환 if thinking_acc and not thinking_finalized: thinking_finalized = True yield history, "", None, run_ids, _thinking_html(thinking_acc, done=True) tts_text += token history[-1]["content"] += token yield history, "", None, run_ids, gr.update() # thinking_box 유지 except Exception as e: history[-1]["content"] += f"\n\n[오류: {e}]" yield history, "", None, run_ids, gr.update() return run_ids.append(collected_run_id) if use_tts: audio_path = await tts_speak(tts_text) yield history, "", audio_path, run_ids, gr.update() else: yield history, "", None, run_ids, gr.update() 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 # idx 위치까지 등장한 assistant 메시지 수 = 이 메시지의 0-based 턴 번호 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: asyncio.run(api_client.save_feedback(user_id, user_msg, asst_msg, rating, run_id)) except Exception as e: print(f"[Feedback] 저장 실패: {e}") def switch_user(user_id): return [], [] def reset_chat(user_id): try: asyncio.run(api_client.reset(user_id)) except Exception as e: print(f"[Reset] 실패: {e}") return [], [] # ── 문서 관리 ───────────────────────────────────────────────────── def ingest_files(files): if not files: return "파일을 선택해주세요." paths = [f if isinstance(f, str) else f.name for f in files] results = [] for path in paths: try: result = asyncio.run(api_client.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 "\n".join(results) def list_docs(): try: sources = asyncio.run(api_client.list_documents()) return [[os.path.basename(s), s] for s in sources] except Exception as e: return [[f"오류: {e}", ""]] def delete_doc(source): if not source.strip(): return "삭제할 파일 경로를 입력하세요.", list_docs() try: asyncio.run(api_client.delete_document(source.strip())) return f"삭제 완료: {os.path.basename(source.strip())}", list_docs() except Exception as e: return f"오류: {e}", list_docs() # ── UI 구성 ────────────────────────────────────────────────────── _THINKING_STYLE = ( "background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;" "padding:10px 14px;max-height:220px;overflow-y:auto;" "font-size:0.85em;color:#555;white-space:pre-wrap;margin-bottom:6px;" ) def _thinking_html(text: str, done: bool = False) -> str: icon = "💭" if done else "🤔" label = "분석 완료" if done else "분석 중..." cursor = "" if done else " ▌" return ( f'