from __future__ import annotations import os from threading import Lock import gradio as gr from megumin_agent.chat import ChatServices from megumin_agent.chat import create_chat_services from megumin_agent.chat import stream_chat INITIAL_GREETING = "내 이름은 메구밍! 홍마족 제일의 마법사이자, 폭렬 마법을 펼치는 자!" INITIAL_HISTORY = [{"role": "assistant", "content": INITIAL_GREETING}] SERVICES: ChatServices | None = None SERVICES_LOCK = Lock() MEGUMIN_IMAGE_URL = os.getenv( "MEGUMIN_IMAGE_URL", "https://huggingface.co/datasets/Junhoee/megumin-chat/resolve/main/%EB%A9%94%EA%B5%AC%EB%B0%8D%EC%82%AC%EC%A7%84.png", ) MEGUMIN_SVG = """ """.strip() CUSTOM_CSS = """ :root { --megumin-panel: rgba(255, 244, 232, 0.12); --megumin-panel-strong: rgba(255, 244, 232, 0.18); --megumin-line: rgba(255, 216, 169, 0.22); --megumin-text: #fff8ef; --megumin-shadow: 0 24px 80px rgba(7, 1, 4, 0.42); } body, .gradio-container { background: radial-gradient(circle at 14% 18%, rgba(255, 145, 71, 0.16), transparent 26%), radial-gradient(circle at 82% 14%, rgba(222, 49, 81, 0.18), transparent 24%), linear-gradient(135deg, #1d0710 0%, #12050b 48%, #26141f 100%); color: var(--megumin-text); } .gradio-container { max-width: 1900px !important; padding: 24px 28px 28px !important; } .megumin-stage { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; } .megumin-stage::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(18, 5, 11, 0.82) 0%, rgba(18, 5, 11, 0.35) 42%, rgba(18, 5, 11, 0.72) 100%); } .stage-portrait { position: absolute; right: 3%; bottom: -1.5rem; width: min(40vw, 560px); opacity: 0.22; filter: drop-shadow(0 30px 80px rgba(0, 0, 0, 0.35)); } .stage-burst { position: absolute; right: 19%; top: 10%; width: 26rem; height: 26rem; border-radius: 999px; background: radial-gradient(circle, rgba(255, 175, 69, 0.35), rgba(255, 119, 53, 0.14), transparent 68%); filter: blur(14px); } .shell { position: relative; z-index: 1; } .main-layout { display: grid; grid-template-columns: 1fr 1.6fr 1fr; gap: 20px; align-items: stretch; } .left-panel { grid-column: 1; } .chat-panel-col { grid-column: 2; } .right-panel { grid-column: 3; } .glass-panel { background: linear-gradient(180deg, var(--megumin-panel-strong) 0%, var(--megumin-panel) 100%); border: 1px solid var(--megumin-line); box-shadow: var(--megumin-shadow); backdrop-filter: blur(14px); border-radius: 28px; overflow: hidden; } .profile-panel, .visual-panel, .chat-panel { min-height: 620px; } .panel-head { padding: 24px 24px 10px; } .eyebrow { font-size: 0.82rem; letter-spacing: 0.14em; text-transform: uppercase; color: rgba(255, 217, 174, 0.82); margin-bottom: 10px; } .panel-title { font-size: 1.95rem; line-height: 1.1; font-weight: 800; margin: 0; color: #ffffff; } .profile-panel .eyebrow, .visual-panel .eyebrow, .profile-panel .panel-title, .visual-panel .panel-title { color: #ffffff !important; } .panel-copy, .box-title, .example-box li, .visual-note p, .quote, .quote small { color: #ffffff !important; } .panel-copy { margin: 12px 0 0; font-size: 0.98rem; line-height: 1.75; } .example-box, .visual-note { margin: 18px 24px 0; padding: 16px 18px 14px; border-radius: 22px; background: rgba(255, 248, 239, 0.08); border: 1px solid rgba(255, 222, 189, 0.12); } .box-title { margin: 0 0 10px; font-size: 0.95rem; font-weight: 700; } .example-box ul { margin: 0; padding-left: 1.1rem; line-height: 1.7; } .example-box li { margin: 0 0 0.45rem; } .chat-hero { display: none; } .chat-header { padding: 26px 28px 10px; display: flex; align-items: end; justify-content: space-between; gap: 16px; } .chat-header h1 { margin: 0; font-size: 2rem; font-weight: 800; color: #ffffff; } .chat-header .eyebrow, .chat-header p, .status-pill { color: #111111 !important; } .status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 999px; background: rgba(255, 209, 143, 0.12); font-size: 0.85rem; border: 1px solid rgba(255, 213, 157, 0.18); } .status-dot { width: 9px; height: 9px; border-radius: 999px; background: #ffb04a; box-shadow: 0 0 18px rgba(255, 176, 74, 0.9); } .mood-badge-slot { margin: 0 18px 10px; } .mood-badge { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; background: rgba(255, 248, 239, 0.12); border: 1px solid rgba(255, 226, 186, 0.16); color: #fff7eb; } .mood-badge::before { content: ""; width: 8px; height: 8px; border-radius: 999px; background: currentColor; box-shadow: 0 0 10px currentColor; } .mood-calm { color: #ffe1b2; } .mood-angry { color: #ff8b6e; } .mood-explosion { color: #ffbe45; } .mood-proud { color: #ffd86a; } .mood-meal { color: #ffd2a8; } .runtime-status { margin: 0 18px 12px; padding: 10px 14px; border-radius: 16px; background: rgba(255, 234, 201, 0.08); border: 1px solid rgba(255, 213, 157, 0.12); color: #fff4dc; font-size: 0.92rem; } .chatbot-wrap { padding: 0 18px 12px; } .chatbot-wrap .message, .chatbot-wrap .bubble { backdrop-filter: blur(10px); } .chatbot-wrap .message.user, .chatbot-wrap .message.user .bubble, .chatbot-wrap .message-row.user .bubble { background: rgba(255, 239, 219, 0.92) !important; color: #3c1a16 !important; } .chatbot-wrap .message.bot, .chatbot-wrap .message.bot .bubble, .chatbot-wrap .message-row.bot .bubble, .chatbot-wrap .message-row.assistant .bubble { background: var(--color-background-secondary, #f4f4f5) !important; color: var(--body-text-color, #1f2937) !important; border: 1px solid var(--border-color-primary, rgba(0, 0, 0, 0.08)) !important; } .chat-history { height: 470px; } .input-zone { padding: 0 18px 18px; } .input-row { display: flex; gap: 12px; align-items: stretch; } .input-message { flex: 8 1 0; min-width: 0; } .input-actions { flex: 2 1 0; min-width: 110px; display: flex; flex-direction: column; gap: 12px; } .input-zone textarea, .input-zone input { color: #111111 !important; } .input-zone .gr-textbox, .input-zone .gr-button, .input-zone .gr-form { background: rgba(255, 248, 239, 0.08) !important; border-color: rgba(255, 226, 186, 0.16) !important; } .input-actions .gr-button { width: 100%; min-height: 48px; } .input-zone .gr-button-primary { background: linear-gradient(135deg, #f19f35 0%, #d84f34 100%) !important; color: #fff8ef !important; border: none !important; } .input-zone .gr-button-secondary { color: #fff8ef !important; } .visual-frame { padding: 24px 22px 16px; min-height: 100%; display: flex; flex-direction: column; justify-content: space-between; } .archive-image-wrap { width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 20px; } .portrait-image, .portrait-fallback { width: 100%; height: 100%; display: block; } .portrait-image { object-fit: cover; box-shadow: 0 22px 40px rgba(0, 0, 0, 0.24); } .portrait-fallback { display: none; } .mobile-hero-image { margin-top: 14px; width: 100%; aspect-ratio: 16 / 9; border-radius: 18px; overflow: hidden; } .mobile-hero-image img, .mobile-hero-image .portrait-fallback { width: 100%; height: 100%; display: block; object-fit: cover; } @media (max-width: 768px) { .gradio-container { padding: 14px 12px 18px !important; } .main-layout { grid-template-columns: 1fr; gap: 14px; } .left-panel, .right-panel { display: none !important; visibility: hidden !important; width: 0 !important; min-width: 0 !important; max-width: 0 !important; padding: 0 !important; margin: 0 !important; overflow: hidden !important; } .left-panel .glass-panel, .right-panel .glass-panel { display: none !important; } .chat-panel-col { grid-column: 1; } .chat-panel { min-height: auto; border-radius: 22px; } .chat-hero { display: block; margin-bottom: 14px; padding: 18px 18px 14px; } .chat-hero .eyebrow { color: #ffffff !important; font-size: 0.72rem; } .chat-hero h1 { margin: 0; font-size: 1.42rem; line-height: 1.15; color: #ffffff; } .chat-hero p { margin: 10px 0 0; color: #ffffff; line-height: 1.5; font-size: 0.88rem; } .chat-header { padding: 14px 14px 6px; } .chat-header .eyebrow, .chat-header p, .status-pill { color: #ffffff !important; } .chat-header h1 { font-size: 1.28rem; } .status-pill { font-size: 0.72rem; padding: 7px 10px; } .mood-badge-slot { margin: 0 12px 8px; } .mood-badge { font-size: 0.72rem; padding: 5px 8px; } .chat-history { height: 34vh !important; min-height: 220px; } .runtime-status { margin: 0 12px 10px; font-size: 0.84rem; } .input-zone { padding: 0 12px 12px; } .input-row { gap: 6px; } .input-actions .gr-button { min-height: 40px; } } """.strip() PROFILE_HTML = """
Character Guide

메구밍 프로필

이 멋진 세계에 축복을!의 등장인물로, 폭렬마법만을 고집하는 홍마족 아크 위저드입니다. 자존심 강하고 허세 넘치는 말투를 즐기지만, 동료들에게는 의외로 진심을 보이는 면도 있습니다. 이 챗봇은 메구밍의 말투와 감정선을 유지하면서 원작 설정까지 함께 참고해 답합니다.

이렇게 시작해보세요

""".strip() VISUAL_HTML = f"""
Explosion Archive
Megumin
{MEGUMIN_SVG}

이 페이지의 분위기

어두운 저녁빛과 폭렬마법의 잔광이 감도는 무드로 구성했습니다. 중앙의 반투명 채팅 패널은 실제로 메구밍과 마주 앉아 이야기를 주고받는 느낌을 주도록 설계했습니다.

"폭렬마법이야말로 궁극의 예술. 그리고 이 대화는, 그 장엄한 서막입니다." Megumin Persona Session
""".strip() MOBILE_HERO_HTML = f"""
Megumin Chat

메구밍과 자유롭게 대화해보세요!

폭렬마법사 메구밍의 말투와 원작 설정을 함께 참고하는 대화형 챗봇입니다.

Megumin
{MEGUMIN_SVG}
""".strip() BACKGROUND_HTML = f"""
{MEGUMIN_SVG}
""".strip() def get_services() -> ChatServices: global SERVICES if SERVICES is None: with SERVICES_LOCK: if SERVICES is None: SERVICES = create_chat_services() return SERVICES def classify_mood(user_text: str, assistant_text: str = "") -> tuple[str, str]: combined = f"{user_text}\n{assistant_text}".lower() if any(token in combined for token in ["바보", "멍청", "웃기", "쓸모없", "닥쳐", "한심"]): return "발끈", "mood-angry" if any(token in combined for token in ["폭렬", "익스플로전", "쏴", "마법"]): return "폭렬 준비", "mood-explosion" if any(token in combined for token in ["밥", "먹", "식사", "배고"]): return "배고픔", "mood-meal" if any(token in combined for token in ["최고", "대단", "멋지", "칭찬", "홍마족"]): return "의기양양", "mood-proud" return "평온", "mood-calm" def render_mood_badge(label: str, mood_class: str) -> str: return f'
상태: {label}
' def current_badge(user_text: str = "", assistant_text: str = "") -> str: label, mood_class = classify_mood(user_text, assistant_text) return render_mood_badge(label, mood_class) def initial_history() -> list[dict[str, str]]: return [dict(item) for item in INITIAL_HISTORY] def begin_request( message: str, history: list[dict[str, str]], session_id: str | None, ): if not message.strip(): return history, session_id, "", "", current_badge() status_text = "서비스 준비 중..." if SERVICES is None else "답변 생성 중..." return history, session_id, message, status_text, current_badge(message) async def respond( message: str, history: list[dict[str, str]], session_id: str | None, ): if not message.strip(): yield history, session_id, "", "", current_badge() return updated_history = list(history) updated_history.append({"role": "user", "content": message}) updated_history.append({"role": "assistant", "content": ""}) yield updated_history, session_id, "", "답변 생성 중...", current_badge(message) active_session_id = session_id got_reply = False async for partial_text, active_session_id in stream_chat( user_message=message, services=get_services(), session_id=session_id, ): got_reply = True updated_history[-1] = {"role": "assistant", "content": partial_text} yield ( updated_history, active_session_id, "", "답변 생성 중...", current_badge(message, partial_text), ) if not got_reply: updated_history[-1] = { "role": "assistant", "content": "오늘은 마력의 흐름이 조금 불안정하군요. 잠시 후 다시 시도해 주시겠습니까?", } yield updated_history, active_session_id, "", "", current_badge( message, updated_history[-1]["content"], ) with gr.Blocks(title="Megumin RAG Chat", fill_height=True) as demo: gr.HTML(BACKGROUND_HTML) with gr.Column(elem_classes=["shell"]): with gr.Row(elem_classes=["main-layout"]): with gr.Column(scale=4, min_width=400, elem_classes=["left-panel"]): gr.HTML(PROFILE_HTML) with gr.Column(scale=6, min_width=720, elem_classes=["chat-panel-col"]): gr.HTML(MOBILE_HERO_HTML) with gr.Group(elem_classes=["glass-panel", "chat-panel"]): gr.HTML( """
Megumin Dialogue Chamber

메구밍 Chatbot

메구밍에게 질문하고, 설정과 감정선을 모두 반영한 답변을 받아보세요.

Try asking about Kazuma, Explosion, or Aqua
""" ) mood_badge = gr.HTML(current_badge(), elem_classes=["mood-badge-slot"]) with gr.Column(elem_classes=["chatbot-wrap"]): chatbot = gr.Chatbot( value=initial_history(), height=470, elem_classes=["chat-history"], render_markdown=True, buttons=["copy"], layout="bubble", label="Megumin Chat", ) runtime_status = gr.Markdown("", elem_classes=["runtime-status"]) session_state = gr.State(value=None) with gr.Column(elem_classes=["input-zone"]): with gr.Row(elem_classes=["input-row"]): with gr.Column(scale=8, min_width=0, elem_classes=["input-message"]): user_input = gr.Textbox( label="Message", placeholder="메구밍에게 말을 걸어 보세요.", lines=2, max_lines=4, ) with gr.Column(scale=2, min_width=110, elem_classes=["input-actions"]): send_button = gr.Button("Send", variant="primary") clear_button = gr.Button("Clear", variant="secondary") with gr.Column(scale=4, min_width=400, elem_classes=["right-panel"]): gr.HTML(VISUAL_HTML) submit_event = user_input.submit( fn=begin_request, inputs=[user_input, chatbot, session_state], outputs=[chatbot, session_state, user_input, runtime_status, mood_badge], ) submit_event.then( fn=respond, inputs=[user_input, chatbot, session_state], outputs=[chatbot, session_state, user_input, runtime_status, mood_badge], ) click_event = send_button.click( fn=begin_request, inputs=[user_input, chatbot, session_state], outputs=[chatbot, session_state, user_input, runtime_status, mood_badge], ) click_event.then( fn=respond, inputs=[user_input, chatbot, session_state], outputs=[chatbot, session_state, user_input, runtime_status, mood_badge], ) clear_button.click( fn=lambda: (initial_history(), None, "", "", current_badge()), inputs=None, outputs=[chatbot, session_state, user_input, runtime_status, mood_badge], ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", css=CUSTOM_CSS, ssr_mode=False)