"""율봇 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
# 즉시 상태 표시 — thinking_acc에 누적하지 않음 (임시 메시지)
if isinstance(token, dict) and "__status" in token:
if not thinking_acc:
yield history, "", None, run_ids, _status_html(token["__status"])
# thinking_acc에 내용 있으면 기존 표시 유지
continue
# 사고 과정(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 구성 ──────────────────────────────────────────────────────
_BOX_STYLE = (
"background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;"
"padding:8px 14px;margin-bottom:6px;"
)
_CONTENT_STYLE = (
"margin-top:8px;white-space:pre-wrap;font-size:0.85em;"
"color:#555;max-height:200px;overflow-y:auto;"
)
def _thinking_html(text: str, done: bool = False) -> str:
"""접기/펼치기 가능한 사고 과정 박스."""
icon = "💭" if done else "🤔"
label = "분석 완료" if done else "분석 중..."
cursor = "" if done else " ▌"
return (
f'{icon} {label}
'
f'