Megumin-chat / app_gradio_messenger.py
Junhoee's picture
Update app_gradio_messenger.py
ed11c3d verified
from __future__ import annotations
import html
import os
from pathlib import Path
from threading import Lock
from urllib.parse import quote
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()
APP_DIR = Path(__file__).resolve().parent
MESSENGER_ALLOWED_PATHS = [str((APP_DIR / "source_file").resolve())]
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
def resolve_megumin_image_path() -> Path:
configured_path = os.getenv("MEGUMIN_IMAGE_PATH", "").strip()
if configured_path:
return Path(configured_path).expanduser().resolve()
source_dir = APP_DIR / "source_file"
preferred_stem = "megumin_profile"
for extension in IMAGE_EXTENSIONS:
candidate = source_dir / f"{preferred_stem}{extension}"
if candidate.exists():
return candidate.resolve()
for extension in IMAGE_EXTENSIONS:
matches = sorted(source_dir.glob(f"*{extension}"))
if matches:
return matches[0].resolve()
return source_dir / f"{preferred_stem}.png"
MEGUMIN_IMAGE_PATH = resolve_megumin_image_path()
MEGUMIN_IMAGE_URL = f"/gradio_api/file={quote(MEGUMIN_IMAGE_PATH.as_posix(), safe='/:')}"
MEGUMIN_SVG = """
<svg viewBox="0 0 420 560" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Megumin inspired illustration">
<defs>
<linearGradient id="cape" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7b0f1b"/>
<stop offset="100%" stop-color="#2c0610"/>
</linearGradient>
<linearGradient id="hat" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#41221f"/>
<stop offset="100%" stop-color="#120b12"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffd86a"/>
<stop offset="100%" stop-color="#e59a22"/>
</linearGradient>
</defs>
<ellipse cx="212" cy="520" rx="120" ry="22" fill="rgba(20,6,10,0.28)"/>
<circle cx="300" cy="104" r="24" fill="rgba(255,114,61,0.28)"/>
<circle cx="334" cy="138" r="12" fill="rgba(255,201,115,0.32)"/>
<path d="M132 180 C116 258 106 344 114 454 L306 454 C314 344 304 258 288 180 Z" fill="url(#cape)"/>
<path d="M174 114 C136 136 120 164 124 206 C132 292 288 292 296 206 C300 164 284 136 246 114 Z" fill="#f6d4bf"/>
<path d="M152 114 C164 74 256 72 274 116 C262 102 240 94 210 94 C182 94 162 100 152 114 Z" fill="#5a2f1f"/>
<path d="M150 110 C120 124 92 170 104 198 C110 212 128 214 146 208 L140 168 C154 156 192 150 228 150 C266 150 298 158 312 170 L308 206 C326 212 344 210 350 198 C362 170 334 124 304 110 C284 88 250 76 210 76 C172 76 140 88 150 110 Z" fill="url(#hat)"/>
<path d="M120 138 L84 154 L108 178 L142 166 Z" fill="#d33b2d"/>
<path d="M310 142 L344 158 L320 180 L286 168 Z" fill="#d33b2d"/>
<path d="M170 274 C194 288 226 290 250 274" stroke="#6a2d1b" stroke-width="8" stroke-linecap="round" fill="none"/>
<circle cx="180" cy="210" r="11" fill="#4a1d14"/>
<circle cx="242" cy="210" r="11" fill="#4a1d14"/>
<path d="M132 108 L170 70 L206 116 Z" fill="url(#gold)"/>
<circle cx="164" cy="92" r="16" fill="#c71f2d"/>
<path d="M246 78 L340 28 L350 54 L264 96 Z" fill="#5a2f1f"/>
<circle cx="338" cy="32" r="16" fill="url(#gold)"/>
<path d="M292 186 L372 78 L388 92 L318 196 Z" fill="#5a2f1f"/>
<path d="M372 74 L388 48 L402 86 Z" fill="#ff8b2f"/>
<path d="M318 196 L364 250" stroke="#5a2f1f" stroke-width="12" stroke-linecap="round"/>
<path d="M340 238 C376 214 410 238 398 276 C386 316 330 318 316 274 C310 256 320 246 340 238 Z" fill="#ff7437" opacity="0.9"/>
<path d="M160 456 L188 300 L232 300 L260 456 Z" fill="#23131a"/>
<path d="M154 302 L114 454" stroke="#120b12" stroke-width="18" stroke-linecap="round"/>
<path d="M266 302 L306 454" stroke="#120b12" stroke-width="18" stroke-linecap="round"/>
</svg>
""".strip()
CUSTOM_CSS = """
:root {
--messenger-bg: #1a0d14; /* ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ */
--messenger-panel: rgba(255, 247, 238, 0.1); /* ๋ฐ˜ํˆฌ๋ช… ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ */
--messenger-panel-strong: rgba(255, 247, 238, 0.14); /* ๋” ์ง„ํ•œ ๋ฐ˜ํˆฌ๋ช… ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ */
--messenger-line: rgba(255, 213, 158, 0.16); /* ๊ฒฝ๊ณ„์„  ์ƒ‰์ƒ */
--messenger-shadow: 0 18px 50px rgba(8, 2, 4, 0.34); /* ํŒจ๋„ ๋ฐ•์Šค ๊ทธ๋ฆผ์ž ์„ค์ • */
--user-bubble: #ffe6c7; /* ์‚ฌ์šฉ์ž ๋งํ’์„  ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ */
--assistant-bubble: #fff7f0; /* ์–ด์‹œ์Šคํ„ดํŠธ ๋งํ’์„  ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ */
--assistant-text: #1f1820; /* ์–ด์‹œ์Šคํ„ดํŠธ ๋งํ’์„  ๊ธ€์ž ์ƒ‰์ƒ */
--messenger-gap: 18px; /* ์ขŒ์šฐ ์„นํ„ฐ ์‚ฌ์ด ๊ฐ„๊ฒฉ */
--messenger-panel-height: clamp(400px, 92vh, 720px); /* ์ „์ฒด ํŒจ๋„ ๋†’์ด */
--messenger-head-height: clamp(60px, 8vh, 80px); /* ์ƒ๋‹จ ํ—ค๋” ๋†’์ด */
--messenger-compose-height: clamp(100px, 16vh, 160px); /* ํ•˜๋‹จ ์ž…๋ ฅ์ฐฝ ๋†’์ด */
}
body, .gradio-container {
background:
radial-gradient(circle at 12% 16%, rgba(255, 145, 71, 0.14), transparent 26%),
radial-gradient(circle at 82% 8%, rgba(217, 47, 78, 0.16), transparent 22%),
linear-gradient(135deg, #1a0d14 0%, #12070d 52%, #221520 100%);
}
footer,
.gradio-container footer {
display: none !important;
}
.gradio-container {
max-width: 1680px !important;
padding: clamp(10px, 1vw, 20px) !important;
}
.messenger-shell {
width: 100%;
}
.messenger-layout {
display: flex !important;
flex-wrap: nowrap !important;
align-items: flex-start !important;
gap: var(--messenger-gap);
}
.messenger-panel {
background: linear-gradient(180deg, var(--messenger-panel-strong), var(--messenger-panel));
border: 1px solid var(--messenger-line);
border-radius: 24px;
box-shadow: var(--messenger-shadow);
backdrop-filter: blur(12px);
overflow: hidden;
height: var(--messenger-panel-height);
min-height: var(--messenger-panel-height);
max-height: var(--messenger-panel-height);
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
height: var(--messenger-panel-height);
max-height: var(--messenger-panel-height);
}
.sidebar-head {
padding: 18px 18px 12px;
border-bottom: 1px solid var(--messenger-line);
}
.sidebar-head h1 {
margin: 0;
font-size: 1.25rem;
color: #fff8ef;
}
.sidebar-head p {
margin: 8px 0 0;
color: rgba(255, 241, 227, 0.8);
font-size: 0.92rem;
}
.profile-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
}
.profile-card {
display: grid;
grid-template-columns: 1fr 4fr; /* ์•„๋ฐ”ํƒ€ ์˜์—ญ / ํ…์ŠคํŠธ ์˜์—ญ (1:4) */
gap: 12px;
align-items: center;
padding: 10px;
border-radius: 16px;
background: rgba(255, 248, 239, 0.08);
border: 1px solid rgba(255, 229, 194, 0.12);
}
.profile-avatar {
width: 100%;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 14px;
overflow: hidden;
background: rgba(255, 248, 239, 0.1);
}
.profile-avatar img,
.profile-avatar .fallback {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar img {
display: block;
}
.profile-avatar .fallback {
display: none;
}
.profile-title {
color: #fff9f2;
font-weight: 700;
font-size: 0.98rem;
}
.profile-snippet {
color: rgba(255, 240, 226, 0.72);
font-size: 0.84rem;
margin-top: 4px;
line-height: 1.4;
}
.chat-stage {
display: grid;
grid-template-rows: var(--messenger-head-height) minmax(0, 1fr) var(--messenger-compose-height);
height: var(--messenger-panel-height);
min-height: 0;
max-height: var(--messenger-panel-height);
overflow: hidden;
}
.chat-stage > .gr-column,
.chat-stage > div {
min-height: 0;
width: 100%;
}
.chat-head-wrap {
height: var(--messenger-head-height);
min-height: var(--messenger-head-height);
max-height: var(--messenger-head-height);
overflow: hidden;
}
.chat-history-wrap {
height: 100%;
min-height: 0;
max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.chat-history-wrap > *,
.chat-history-wrap > * > * {
height: 100% !important;
min-height: 0 !important;
max-height: 100% !important;
overflow: hidden !important;
}
.chat-head {
height: var(--messenger-head-height);
box-sizing: border-box;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--messenger-line);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.chat-head-main {
display: grid;
grid-template-columns: 1fr 6fr;
gap: 12px;
align-items: center; /* ์„ธ๋กœ ๊ฐ€์šด๋ฐ */
justify-content: center; /* ๊ฐ€๋กœ ๊ฐ€์šด๋ฐ */
}
.chat-head-avatar {
width: 80%;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 16px;
overflow: hidden;
}
.chat-head-avatar img,
.chat-head-avatar .fallback {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-head-avatar img {
display: block;
}
.chat-head-avatar .fallback {
display: none;
}
.chat-head-title {
color: #fff8ef;
font-size: 1.08rem;
font-weight: 700;
}
.chat-head-desc {
color: rgba(255, 239, 223, 0.75);
font-size: 0.86rem;
margin-top: 4px;
}
.chat-status {
color: rgba(255, 233, 203, 0.86);
font-size: 0.82rem;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 239, 219, 0.08);
border: 1px solid rgba(255, 219, 169, 0.12);
}
.chat-history {
height: 100% !important;
min-height: 0 !important;
max-height: 100% !important;
overflow-y: auto !important;
overflow-x: hidden !important;
box-sizing: border-box;
padding: 8px 8px 8px;
}
.message-col {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-row {
display: flex;
width: 100%;
}
.message-row.assistant {
justify-content: flex-start;
}
.message-row.user {
justify-content: flex-end;
}
.assistant-wrap {
display: grid;
grid-template-columns: 1fr 8fr;
gap: 10px;
max-width: 84%;
align-items: start;
}
.assistant-avatar {
width: 100%;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 12px;
overflow: hidden;
}
.assistant-avatar img,
.assistant-avatar .fallback {
width: 100%;
height: 100%;
object-fit: cover;
}
.assistant-avatar img {
display: block;
}
.assistant-avatar .fallback {
display: none;
}
.bubble {
padding: 12px 14px;
border-radius: 18px;
line-height: 1.6;
font-size: 0.95rem;
white-space: pre-wrap;
word-break: break-word;
backdrop-filter: blur(8px);
}
.assistant-bubble {
background: var(--assistant-bubble);
color: var(--assistant-text);
border-top-left-radius: 8px;
}
.user-bubble {
max-width: 72%;
background: var(--user-bubble);
color: #2f1e1a;
border-top-right-radius: 8px;
}
.chat-compose {
height: var(--messenger-compose-height);
min-height: var(--messenger-compose-height);
max-height: var(--messenger-compose-height);
box-sizing: border-box;
padding: 2px 16px 16px;
border-top: 1px solid var(--messenger-line);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center; /* ์„ธ๋กœ ๊ฐ€์šด๋ฐ */
align-items: center; /* ๊ฐ€๋กœ ๊ฐ€์šด๋ฐ */
}
.compose-status {
margin-bottom: 10px;
color: rgba(255, 239, 223, 0.82);
font-size: 0.86rem;
}
.compose-row {
display: grid;
grid-template-columns: 5fr 1fr;
gap: 10px;
align-items: stretch;
}
.compose-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.compose-row textarea,
.compose-row input {
color: #111111 !important;
}
.compose-row .gr-textbox,
.compose-row .gr-button,
.compose-row .gr-form {
background: rgba(255, 248, 239, 0.08) !important;
border-color: rgba(255, 226, 186, 0.16) !important;
}
.compose-buttons .gr-button {
min-height: 46px;
}
.compose-row .gr-button-primary {
background: linear-gradient(135deg, #f19f35 0%, #d84f34 100%) !important;
color: #fff8ef !important;
border: none !important;
}
.compose-row .gr-button-secondary {
color: #fff8ef !important;
}
@media (max-width: 900px) {
:root {
--messenger-gap: 8px;
--messenger-panel-height: clamp(400px, 92vh, 720px);
--messenger-head-height: clamp(60px, 8vh, 80px);
--messenger-compose-height: clamp(100px, 16vh, 160px);
}
.gradio-container {
padding: 6px !important;
}
.messenger-layout {
flex-direction: row !important;
flex-wrap: nowrap !important;
}
.sidebar {
height: var(--messenger-panel-height);
min-height: var(--messenger-panel-height);
max-height: var(--messenger-panel-height);
}
.chat-stage {
height: var(--messenger-panel-height);
min-height: var(--messenger-panel-height);
max-height: var(--messenger-panel-height);
}
.chat-history-wrap {
height: 100%;
min-height: 0;
max-height: 100%;
}
.chat-history-wrap > *,
.chat-history-wrap > * > * {
height: 100% !important;
min-height: 0 !important;
max-height: 100% !important;
}
.assistant-wrap {
max-width: 94%;
}
.user-bubble {
max-width: 86%;
}
.chat-head-desc {
font-size: 0.76rem;
}
.chat-status {
font-size: 0.72rem;
padding: 6px 10px;
}
.profile-snippet {
font-size: 0.76rem;
}
.bubble {
font-size: 0.8rem;
line-height: 1.5;
}
}
""".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 initial_history() -> list[dict[str, str]]:
return [dict(item) for item in INITIAL_HISTORY]
def truncate_text(text: str, limit: int = 34) -> str:
compact = " ".join(str(text or "").split()).strip()
if len(compact) <= limit:
return compact
return compact[: limit - 3].rstrip() + "..."
def render_avatar(size_class: str) -> str:
return f"""
<div class="{size_class}">
<img src="{MEGUMIN_IMAGE_URL}" alt="Megumin" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';" />
<div class="fallback">{MEGUMIN_SVG}</div>
</div>
""".strip()
def render_sidebar(history: list[dict[str, str]]) -> str:
last_message = next(
(item["content"] for item in reversed(history) if item.get("content")),
INITIAL_GREETING,
)
return f"""
<div class="sidebar-head">
<h1>Characters</h1>
<p>ํ˜„์žฌ ๋Œ€ํ™” ์ค‘์ธ ์บ๋ฆญํ„ฐ๋ฅผ ์—ฌ๊ธฐ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
</div>
<div class="profile-list">
<div class="profile-card">
{render_avatar("profile-avatar")}
<div>
<div class="profile-title">๋ฉ”๊ตฌ๋ฐ</div>
<div class="profile-snippet">{html.escape(truncate_text(last_message))}</div>
</div>
</div>
</div>
""".strip()
def render_messages(history: list[dict[str, str]]) -> str:
rows: list[str] = []
for item in history:
role = item.get("role", "")
content = html.escape(item.get("content", ""))
if role == "assistant":
rows.append(
f"""
<div class="message-row assistant">
<div class="assistant-wrap">
{render_avatar("assistant-avatar")}
<div class="bubble assistant-bubble">{content}</div>
</div>
</div>
""".strip()
)
else:
rows.append(
f"""
<div class="message-row user">
<div class="bubble user-bubble">{content}</div>
</div>
""".strip()
)
return f'<div class="message-col">{"".join(rows)}</div>'
def begin_request(
message: str,
history: list[dict[str, str]],
session_id: str | None,
):
if not message.strip():
return render_sidebar(history), render_messages(history), history, session_id, "", ""
status_text = "์„œ๋น„์Šค ์ค€๋น„ ์ค‘..." if SERVICES is None else "๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘..."
return render_sidebar(history), render_messages(history), history, session_id, message, status_text
async def respond(
message: str,
history: list[dict[str, str]],
session_id: str | None,
):
if not message.strip():
yield render_sidebar(history), render_messages(history), history, session_id, "", ""
return
updated_history = list(history)
updated_history.append({"role": "user", "content": message})
updated_history.append({"role": "assistant", "content": ""})
yield (
render_sidebar(updated_history),
render_messages(updated_history),
updated_history,
session_id,
"",
"๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘...",
)
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 (
render_sidebar(updated_history),
render_messages(updated_history),
updated_history,
active_session_id,
"",
"๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘...",
)
if not got_reply:
updated_history[-1] = {
"role": "assistant",
"content": "์˜ค๋Š˜์€ ๋งˆ๋ ฅ์˜ ํ๋ฆ„์ด ์กฐ๊ธˆ ๋ถˆ์•ˆ์ •ํ•˜๊ตฐ์š”. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?",
}
yield (
render_sidebar(updated_history),
render_messages(updated_history),
updated_history,
active_session_id,
"",
"",
)
with gr.Blocks(title="Megumin Messenger") as demo:
history_state = gr.State(value=initial_history())
session_state = gr.State(value=None)
with gr.Column(elem_classes=["messenger-shell"]):
with gr.Row(elem_classes=["messenger-layout"]):
with gr.Column(scale=3, min_width=96, elem_classes=["messenger-panel", "sidebar"]):
sidebar_html = gr.HTML(render_sidebar(initial_history()))
with gr.Column(scale=7, min_width=240, elem_classes=["messenger-panel", "chat-stage"]):
gr.HTML(
f"""
<div class="chat-head">
<div class="chat-head-main">
{render_avatar("chat-head-avatar")}
<div>
<div class="chat-head-title">๋ฉ”๊ตฌ๋ฐ</div>
<div class="chat-head-desc">ํ™๋งˆ์กฑ ์ตœ๊ณ ์˜ ํญ๋ ฌ๋งˆ๋ฒ•์‚ฌ์™€ ์ž์œ ๋กญ๊ฒŒ ๋Œ€ํ™”ํ•ด๋ณด์„ธ์š”.</div>
</div>
</div>
<div class="chat-status">Megumin Messenger</div>
</div>
""".strip()
,
elem_classes=["chat-head-wrap"],
)
with gr.Column(elem_classes=["chat-history-wrap"]):
messages_html = gr.HTML(
render_messages(initial_history()),
elem_classes=["chat-history"],
container=False,
)
with gr.Column(elem_classes=["chat-compose"]):
runtime_status = gr.Markdown("", elem_classes=["compose-status"])
with gr.Row(elem_classes=["compose-row"]):
user_input = gr.Textbox(
show_label=False,
placeholder="๋ฉ”๊ตฌ๋ฐ์—๊ฒŒ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด๋ณด์„ธ์š”.",
lines=2,
max_lines=5,
)
with gr.Column(elem_classes=["compose-buttons"]):
send_button = gr.Button("Send", variant="primary")
clear_button = gr.Button("Clear", variant="secondary")
submit_event = user_input.submit(
fn=begin_request,
inputs=[user_input, history_state, session_state],
outputs=[sidebar_html, messages_html, history_state, session_state, user_input, runtime_status],
)
submit_event.then(
fn=respond,
inputs=[user_input, history_state, session_state],
outputs=[sidebar_html, messages_html, history_state, session_state, user_input, runtime_status],
)
click_event = send_button.click(
fn=begin_request,
inputs=[user_input, history_state, session_state],
outputs=[sidebar_html, messages_html, history_state, session_state, user_input, runtime_status],
)
click_event.then(
fn=respond,
inputs=[user_input, history_state, session_state],
outputs=[sidebar_html, messages_html, history_state, session_state, user_input, runtime_status],
)
clear_button.click(
fn=lambda: (
render_sidebar(initial_history()),
render_messages(initial_history()),
initial_history(),
None,
"",
"",
),
inputs=None,
outputs=[sidebar_html, messages_html, history_state, session_state, user_input, runtime_status],
)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
css=CUSTOM_CSS,
ssr_mode=False,
allowed_paths=MESSENGER_ALLOWED_PATHS,
)