"""
Murasaki 翻译模型在线演示
布局: 两栏 + 底部可折叠思考面板
"""
import os, re, json, requests, time, ipaddress
from datetime import date
from typing import Tuple
import gradio as gr
# ============================================================
# 配置
# ============================================================
MODAL_ENDPOINT_URL = os.getenv("MODAL_ENDPOINT_URL", "http://localhost:8000")
DEMO_SECRET_TOKEN = os.getenv("DEMO_SECRET_TOKEN", "")
ADMIN_BYPASS_TOKEN = os.getenv("ADMIN_BYPASS_TOKEN", "")
UPSTASH_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
UPSTASH_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
DAILY_CHAR_QUOTA = 50000
DAILY_REQ_QUOTA = 100
MIN_TEXT_LENGTH = 100
MAX_REQUEST_SIZE = 10000
MAX_CHUNK_SIZE = 1500
MIN_CHUNK_SIZE = 1000
REQUEST_TIMEOUT = 300
ACTIVE_USER_WINDOW_SEC = 120
GITHUB_URL = "https://github.com/soundstarrain/Murasaki-Translator"
HF_REPO_URL = "https://huggingface.co/Murasaki-Project"
GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0])
ADMIN_HOTKEY_HEAD = """
"""
# ============================================================
# 辅助函数
# ============================================================
def estimate_tokens(text: str) -> int:
return max(1, int(len(text) / 1.5))
def post_process(text: str) -> str:
lines = []
for line in text.splitlines():
if line.count('"') > 0 and line.count('"') % 2 == 0:
line = re.sub(r'"([^"]*)"', r'「\1」', line)
if line.count("'") > 0 and line.count("'") % 2 == 0:
line = re.sub(r"'([^']*)'", r'『\1』', line)
lines.append(line)
return "\n\n".join([l.rstrip() for l in "\n".join(lines).splitlines() if l.strip()])
def stream_parse(raw: str) -> Tuple[str, str]:
if "" in raw:
before, after = raw.split("", 1)
if "" in after:
think_blocks = re.findall(r"(.*?)", raw, flags=re.DOTALL)
thinking = "\n\n".join([t.strip() for t in think_blocks if t.strip()])
content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip()
content = content.replace("", "").replace("", "").strip()
return thinking, content
return after.strip(), before.strip()
if "" in raw:
return "", raw.replace("", "").strip()
return "", raw.strip()
def accordion_update(open_flag: bool):
if GRADIO_MAJOR >= 6:
return gr.Accordion(open=open_flag)
return gr.update(open=open_flag)
# ============================================================
# Redis
# ============================================================
def get_redis():
if not UPSTASH_URL or not UPSTASH_TOKEN: return None
try:
from upstash_redis import Redis
return Redis(url=UPSTASH_URL, token=UPSTASH_TOKEN)
except: return None
def check_quota(ip, chars):
r = get_redis()
if not r: return True, DAILY_CHAR_QUOTA, DAILY_REQ_QUOTA, ""
try:
ck, rk = f"demo:c:{ip}:{date.today()}", f"demo:r:{ip}:{date.today()}"
uc, ur = int(r.get(ck) or 0), int(r.get(rk) or 0)
rc, rr = DAILY_CHAR_QUOTA - uc, DAILY_REQ_QUOTA - ur
if rr <= 0: return False, rc, 0, "请求次数用尽"
if chars > rc: return False, rc, rr, "字符额度不足"
return True, rc, rr, ""
except: return True, DAILY_CHAR_QUOTA, DAILY_REQ_QUOTA, ""
def deduct_quota(ip, chars):
r = get_redis()
if not r: return DAILY_CHAR_QUOTA, DAILY_REQ_QUOTA
try:
ck, rk = f"demo:c:{ip}:{date.today()}", f"demo:r:{ip}:{date.today()}"
audit_prefix = f"demo:ip:{ip}"
now_ts = str(int(time.time()))
# 永久审计信息(不设置过期)
if not r.get(f"{audit_prefix}:first_seen"):
r.set(f"{audit_prefix}:first_seen", now_ts)
r.set(f"{audit_prefix}:last_seen", now_ts)
r.incrby(f"{audit_prefix}:total_chars", chars)
r.incr(f"{audit_prefix}:total_reqs")
r.incrby(ck, chars); r.incr(rk); r.expire(ck, 86400); r.expire(rk, 86400)
return max(0, DAILY_CHAR_QUOTA - int(r.get(ck) or 0)), max(0, DAILY_REQ_QUOTA - int(r.get(rk) or 0))
except: return DAILY_CHAR_QUOTA, DAILY_REQ_QUOTA
def _safe_int(value, default=0):
try:
return int(value)
except:
return default
def _cleanup_active_users(r, now_ts: int) -> int:
key = "demo:active:users"
data = r.hgetall(key) or {}
active_count = 0
for user_ip, last_ts in data.items():
if now_ts - _safe_int(last_ts, 0) > ACTIVE_USER_WINDOW_SEC:
try:
r.hdel(key, user_ip)
except:
pass
else:
active_count += 1
return active_count
def mark_task_start(ip: str):
r = get_redis()
if not r:
return
try:
now_ts = int(time.time())
users_key = "demo:active:users"
tasks_key = "demo:active:tasks"
r.hset(users_key, ip, now_ts)
_cleanup_active_users(r, now_ts)
r.incr(tasks_key)
except:
pass
def mark_task_end(ip: str):
r = get_redis()
if not r:
return
try:
now_ts = int(time.time())
users_key = "demo:active:users"
tasks_key = "demo:active:tasks"
r.hset(users_key, ip, now_ts)
_cleanup_active_users(r, now_ts)
current = _safe_int(r.get(tasks_key), 0)
if current > 0:
r.decr(tasks_key)
except:
pass
TOTAL_CHARS_KEY = "demo:stats:chars_total"
TOTAL_TOKENS_KEY = "demo:stats:tokens_out_total"
BACKFILL_DONE_KEY = "demo:stats:backfill_done_v1"
def add_total_stats(input_chars: int, output_tokens: int):
r = get_redis()
if not r:
return
try:
r.incrby(TOTAL_CHARS_KEY, int(input_chars))
r.incrby(TOTAL_TOKENS_KEY, int(output_tokens))
except:
pass
def backfill_totals_if_needed(r):
try:
if r.get(BACKFILL_DONE_KEY):
return
current_chars = _safe_int(r.get(TOTAL_CHARS_KEY), 0)
current_tokens = _safe_int(r.get(TOTAL_TOKENS_KEY), 0)
daily_chars_sum = 0
daily_tokens_sum = 0
try:
char_keys = r.keys("demo:stats:chars:*") or []
token_keys = r.keys("demo:stats:tokens_out:*") or []
for key in char_keys:
daily_chars_sum += _safe_int(r.get(key), 0)
for key in token_keys:
daily_tokens_sum += _safe_int(r.get(key), 0)
except Exception:
char_keys = []
token_keys = []
if daily_chars_sum > current_chars:
r.set(TOTAL_CHARS_KEY, daily_chars_sum)
if daily_tokens_sum > current_tokens:
r.set(TOTAL_TOKENS_KEY, daily_tokens_sum)
# 标记回填完成,避免反复扫描 keys 降低读负担
r.set(BACKFILL_DONE_KEY, 1)
except:
pass
def get_live_stats_line() -> str:
headers = {}
if DEMO_SECRET_TOKEN:
headers["Authorization"] = f"Bearer {DEMO_SECRET_TOKEN}"
# 1) 优先使用后端统计(单一真源,避免前后端口径不一致)
try:
live_resp = requests.get(
f"{MODAL_ENDPOINT_URL}/live/summary?window_seconds={ACTIVE_USER_WINDOW_SEC}",
headers=headers,
timeout=6,
)
if live_resp.status_code == 200:
live = live_resp.json()
online_users = _safe_int(live.get("online_users"), 0)
active_tasks = _safe_int(live.get("running"), 0)
total_translations = _safe_int(live.get("total_translations"), 0)
total_chars = _safe_int(live.get("total_input_chars"), 0)
total_tokens = _safe_int(live.get("total_output_tokens"), 0)
return (
f"在线用户:{online_users:,} | 进行中任务:{active_tasks} | "
f"总翻译次数:{total_translations:,} | 总翻译字数:{total_chars:,} | "
f"总输出Token:{total_tokens:,}"
)
except Exception:
pass
# 2) 后端异常时回退到本地 Redis 统计
r = get_redis()
if not r:
return "在线统计:后端接口不可用,且未连接 Redis"
try:
backfill_totals_if_needed(r)
now_ts = int(time.time())
active_users = _cleanup_active_users(r, now_ts)
active_tasks = _safe_int(r.get("demo:active:tasks"), 0)
total_chars = _safe_int(r.get(TOTAL_CHARS_KEY), 0)
total_tokens = _safe_int(r.get(TOTAL_TOKENS_KEY), 0)
return (
f"在线用户:{active_users} | 进行中任务:{active_tasks} | "
f"总翻译次数:- | 总翻译字数:{total_chars:,} | 总输出Token:{total_tokens:,}"
)
except Exception:
return "在线统计:读取失败"
def is_public_ip(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip.strip())
return addr.is_global
except Exception:
return False
def extract_client_ip(req: gr.Request) -> str:
if not req:
return "x"
try:
headers = {str(k).lower(): str(v) for k, v in (req.headers or {}).items()}
xff = headers.get("x-forwarded-for", "")
if xff:
first_ip = xff.split(",")[0].strip()
if first_ip and is_public_ip(first_ip):
return first_ip
for key in ("cf-connecting-ip", "x-real-ip", "x-client-ip"):
val = headers.get(key, "").strip()
if val and is_public_ip(val):
return val
except Exception:
pass
try:
host = req.client.host if req and req.client and req.client.host else "x"
return host if is_public_ip(host) else "x"
except Exception:
return "x"
# ============================================================
# 翻译逻辑
# ============================================================
def split_chunks(text):
if len(text) <= MAX_CHUNK_SIZE: return [text]
paragraphs = re.split(r'\n\n+', text); chunks, cur = [], ""
for p in paragraphs:
if len(p) > MAX_CHUNK_SIZE:
if cur: chunks.append(cur.strip()); cur = ""
for s in re.split(r'(?<=[。!?\n])', p):
if len(cur) + len(s) <= MAX_CHUNK_SIZE: cur += s
else:
if cur: chunks.append(cur.strip())
cur = s
elif len(cur) + len(p) + 2 <= MAX_CHUNK_SIZE: cur += ("\n\n" if cur else "") + p
elif len(cur) >= MIN_CHUNK_SIZE: chunks.append(cur.strip()); cur = p
else: cur += ("\n\n" if cur else "") + p
if cur: chunks.append(cur.strip())
return chunks
def translate_stream(text, model, preset, ip, admin_bypass=False):
text = text.strip()
if not text: yield "", "", "输入为空"; return
if len(text) < MIN_TEXT_LENGTH: yield "", "", f"最少 {MIN_TEXT_LENGTH} 字"; return
if len(text) > MAX_REQUEST_SIZE: yield "", "", f"最多 {MAX_REQUEST_SIZE} 字"; return
if not admin_bypass:
ok, rc, rr, err = check_quota(ip, len(text))
if not ok: yield "", "", f"限额已用尽: {err}"; return
mark_task_start(ip)
try:
start_time = time.time()
yield "", "", "请求已提交:高峰期可能排队,冷启动约 20-40 秒,请稍候..."
input_tokens = estimate_tokens(text)
chunks = split_chunks(text)
total_chunks = len(chunks)
if total_chunks > 1:
yield "", "", f"检测到长文本(>{MAX_CHUNK_SIZE}字),将分 {total_chunks} 块翻译;思维链与正文会按块交替更新,请耐心等待。"
headers = {"Content-Type": "application/json"}
if DEMO_SECRET_TOKEN: headers["Authorization"] = f"Bearer {DEMO_SECRET_TOKEN}"
if ip and ip != "x":
headers["X-Client-IP"] = ip
trans, think, total_output = "", "", ""
for i, chunk in enumerate(chunks):
try:
yield trans, think, f"准备处理第 {i+1}/{total_chunks} 块:正在连接后端并等待首字..."
resp = requests.post(
f"{MODAL_ENDPOINT_URL}/translate",
json={"text": chunk, "model": model, "preset": preset, "stream": True},
headers=headers,
stream=True,
timeout=REQUEST_TIMEOUT,
)
if resp.status_code != 200:
yield trans, think, "服务繁忙或排队超时,请稍后重试"
return
raw = ""
has_first_token = False
for line in resp.iter_lines(decode_unicode=True):
if line and line.startswith("data: "):
d = line[6:]
if d == "[DONE]":
break
data = json.loads(d)
if "error" in data:
yield trans, think, f"服务错误: {data['error']}"
return
raw += data.get("content", "")
if (not has_first_token) and raw.strip():
has_first_token = True
ct, cr = stream_parse(raw)
elapsed = time.time() - start_time
status = f"处理中 {i+1}/{len(chunks)} · {elapsed:.1f}s"
yield trans + ("\n\n" if trans and cr else "") + cr, think + ("\n" if think and ct else "") + ct, status
chunk_think, chunk_trans = stream_parse(raw)
trans += ("\n\n" if trans else "") + post_process(chunk_trans)
total_output += raw
if chunk_think:
think += ("\n\n" if think else "") + chunk_think
if i < total_chunks - 1:
yield trans, think, f"第 {i+1}/{total_chunks} 块完成,正在切换到第 {i+2} 块..."
except Exception as e:
yield trans, think, str(e)
return
elapsed = time.time() - start_time
output_tokens = estimate_tokens(total_output)
speed = output_tokens / elapsed if elapsed > 0 else 0
if admin_bypass:
yield trans, think, f"完成(Admin) · 输入 {input_tokens} tokens · 输出 {output_tokens} tokens · {elapsed:.1f}s ({speed:.1f} t/s)"
else:
rc, rr = deduct_quota(ip, len(text))
yield trans, think, f"完成 · 输入 {input_tokens} tokens · 输出 {output_tokens} tokens · {elapsed:.1f}s ({speed:.1f} t/s) · 剩余 {rc:,}字/{rr}次"
finally:
mark_task_end(ip)
# ============================================================
# 自定义主题 - 白色背景 + 浅紫色关键文字
# ============================================================
theme = gr.themes.Soft(
primary_hue="violet",
secondary_hue="purple",
neutral_hue="slate",
).set(
body_background_fill="#fafafa",
body_background_fill_dark="#fafafa",
block_background_fill="#ffffff",
block_background_fill_dark="#ffffff",
input_background_fill="#f8f9fa",
input_background_fill_dark="#f8f9fa",
button_primary_background_fill="#8b5cf6",
button_primary_background_fill_dark="#8b5cf6",
button_primary_background_fill_hover="#7c3aed",
button_primary_background_fill_hover_dark="#7c3aed",
body_text_color="#374151",
body_text_color_dark="#374151",
body_text_color_subdued="#6b7280",
body_text_color_subdued_dark="#6b7280",
block_label_background_fill="transparent",
block_label_background_fill_dark="transparent",
block_label_text_color="#8b5cf6",
block_label_text_color_dark="#8b5cf6",
block_title_text_color="#8b5cf6",
block_title_text_color_dark="#8b5cf6",
block_border_width="1px",
block_border_color="#e5e7eb",
block_border_color_dark="#e5e7eb",
input_border_width="1px",
input_border_color="#e5e7eb",
input_border_color_dark="#e5e7eb",
block_radius="12px",
input_radius="8px",
)
CSS = """
/* ============================================
全局字体基准
============================================ */
.gradio-container {
font-size: 16px !important;
}
/* 标题区 */
.app-header {
text-align: center;
padding: 6px 0 2px 0;
}
.app-header h1 {
font-size: 1.45rem;
font-weight: 700;
color: #8b5cf6;
margin: 0;
letter-spacing: -0.02em;
}
.app-header p {
color: #6b7280;
font-size: 0.85rem;
margin: 0;
}
/* 说明框 */
.info-box {
background: #f3f0ff;
border: 1px solid #e9d5ff;
border-radius: 8px;
padding: 6px 12px;
margin-bottom: 6px;
}
.info-box h3 {
color: #7c3aed;
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 6px 0;
}
.info-box p {
color: #4b5563;
font-size: 0.9rem;
line-height: 1.5;
margin: 3px 0;
}
.info-box a {
color: #8b5cf6;
text-decoration: none;
font-weight: 500;
}
.info-box a:hover {
text-decoration: underline;
}
/* 面板标签 */
.panel-label {
color: #8b5cf6;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
#live_stats_bar {
background: linear-gradient(90deg, #eef2ff 0%, #f5f3ff 100%) !important;
border: 1px solid #c4b5fd !important;
border-radius: 10px !important;
padding: 8px 12px !important;
margin: 4px 0 8px 0 !important;
}
#live_stats_bar p {
margin: 0 !important;
color: #5b21b6 !important;
font-size: 0.92rem !important;
font-weight: 600 !important;
}
#live_stats_bar, #live_stats_bar * {
opacity: 1 !important;
filter: none !important;
}
/* ============================================
全局边框重置 - 移除所有嵌套边框
============================================ */
/* 所有 block 容器移除边框 */
.gradio-container .block {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
/* Form 容器移除边框 */
.gradio-container .form {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
/* 嵌套的 wrap 容器移除边框 */
.gradio-container .wrap {
border: none !important;
box-shadow: none !important;
}
/* ============================================
文本框 - 只在 textarea 本身保留边框
============================================ */
.gradio-textbox {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.gradio-textbox > .wrap,
.gradio-textbox > div {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
textarea {
background: #f8f9fa !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
color: #374151 !important;
font-size: 0.95rem !important;
line-height: 1.6 !important;
padding: 10px 12px !important;
}
textarea::placeholder {
color: #9ca3af !important;
font-size: 0.95rem !important;
}
textarea:focus {
border-color: #a78bfa !important;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1) !important;
}
/* ============================================
下拉框 - 只保留外层单一边框
============================================ */
.gradio-dropdown {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.gradio-dropdown > .wrap,
.gradio-dropdown > div:not(ul) {
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
min-height: 38px !important;
}
.gradio-dropdown .wrap .wrap,
.gradio-dropdown .secondary-wrap,
.gradio-dropdown .border-none {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.gradio-dropdown input,
.gradio-dropdown select {
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: #374151 !important;
font-size: 0.95rem !important;
}
/* 下拉菜单列表 */
.gradio-dropdown ul[role="listbox"],
.gradio-container ul[role="listbox"] {
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
}
.gradio-dropdown ul li,
.gradio-container ul[role="listbox"] li {
background: #ffffff !important;
color: #374151 !important;
}
.gradio-dropdown ul li:hover,
.gradio-container ul[role="listbox"] li:hover {
background: #f3f0ff !important;
}
/* ============================================
折叠面板 - 简洁单边框
============================================ */
.gradio-accordion {
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
overflow: hidden;
margin-top: 8px !important;
}
.gradio-accordion > div,
.gradio-accordion .label-wrap,
.gradio-accordion button {
border: none !important;
box-shadow: none !important;
background: #ffffff !important;
color: #374151 !important;
font-size: 0.95rem !important;
padding: 8px 12px !important;
}
.gradio-accordion button span {
color: #8b5cf6 !important;
font-size: 0.95rem !important;
font-weight: 600 !important;
}
.gradio-accordion .content {
border-top: 1px solid #e5e7eb !important;
padding: 8px 12px !important;
}
/* ============================================
按钮
============================================ */
button.primary {
margin-top: 16px !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
padding: 8px 20px !important;
min-height: 40px !important;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25) !important;
border: none !important;
border-radius: 8px !important;
transition: all 0.15s ease !important;
}
button.primary:hover {
box-shadow: 0 3px 12px rgba(139, 92, 246, 0.35) !important;
transform: translateY(-1px) !important;
}
/* ============================================
状态栏和禁用输入
============================================ */
input:disabled, textarea:disabled {
background: #f8f9fa !important;
color: #6b7280 !important;
border: 1px solid #e5e7eb !important;
}
/* Row 容器 */
.gradio-row {
border: none !important;
box-shadow: none !important;
}
/* Column 容器 */
.gradio-column {
border: none !important;
box-shadow: none !important;
}
/* 标签文字 */
.gradio-container label,
.gradio-container .label-wrap span {
font-size: 0.95rem !important;
font-weight: 500 !important;
color: #8b5cf6 !important;
}
/* 状态栏 */
.gradio-container input[type="text"]:disabled {
font-size: 0.95rem !important;
color: #6b7280 !important;
}
/* 全局深色覆盖 */
.dark, [data-theme="dark"] {
--background-fill-primary: #fafafa !important;
--background-fill-secondary: #f8f9fa !important;
--block-background-fill: transparent !important;
--input-background-fill: #f8f9fa !important;
--body-text-color: #374151 !important;
}
footer { display: none !important; }
"""
blocks_kwargs = {"title": "Murasaki LLM 在线演示"}
if GRADIO_MAJOR < 6:
blocks_kwargs["theme"] = theme
blocks_kwargs["css"] = CSS
blocks_kwargs["head"] = ADMIN_HOTKEY_HEAD
with gr.Blocks(**blocks_kwargs) as demo:
gr.HTML("""
""")
with gr.Accordion("使用说明(点击展开)", open=False):
gr.HTML(f"""
1. 在线演示限额:每日 {DAILY_REQ_QUOTA} 次请求 / {DAILY_CHAR_QUOTA:,} 字符,单次 {MIN_TEXT_LENGTH}-{MAX_REQUEST_SIZE} 字符
2. 首次冷启动需要约 30 秒加载模型,请耐心等待
3. 输入超过 {MAX_CHUNK_SIZE} 字会自动分块翻译,CoT 与正文会按块交替更新(块切换时会短暂等待首字)
4. 推荐从 模型仓库 下载模型配合 Murasaki Translator 本地运行
""")
live_stats = gr.Markdown("统计加载中...", elem_id="live_stats_bar")
with gr.Row():
with gr.Column():
gr.HTML("原文
")
src = gr.Textbox(placeholder="粘贴日语原文...", lines=8, show_label=False, elem_id="src_text")
with gr.Column():
gr.HTML("译文
")
res = gr.Textbox(placeholder="翻译结果将在此显示...", lines=8, show_label=False, interactive=False, elem_id="res_text")
with gr.Row():
m = gr.Dropdown(choices=["Murasaki-8B-v0.2", "Murasaki-14B-v0.2"], value="Murasaki-8B-v0.2", label="模型", interactive=True, scale=2)
p = gr.Dropdown(choices=["轻小说", "剧本"], value="轻小说", label="预设", interactive=True, scale=2)
btn = gr.Button("翻译", variant="primary", scale=1)
admin_pwd = gr.Textbox(label="Admin Key (optional)", type="password", value="", visible=False)
thinking_accordion = gr.Accordion("AI 推理过程", open=True, elem_id="thinking_panel")
with thinking_accordion:
cot = gr.Textbox(placeholder="模型思考过程...", lines=5, show_label=False, interactive=False, elem_id="cot_text")
status = gr.Textbox(show_label=False, interactive=False, value="准备就绪", container=False, elem_id="status_text")
def translate_wrapper(text, model, preset, admin_key, req: gr.Request):
model_map = {"Murasaki-8B-v0.2": "8b", "Murasaki-14B-v0.2": "14b"}
preset_map = {"轻小说": "novel", "剧本": "script"}
admin_bypass = bool(ADMIN_BYPASS_TOKEN and admin_key and admin_key == ADMIN_BYPASS_TOKEN)
client_ip = extract_client_ip(req)
for trans, think, st in translate_stream(text, model_map.get(model, "8b"), preset_map.get(preset, "novel"), client_ip, admin_bypass=admin_bypass):
yield trans, think, st
sync_admin_key_js = "(text, model, preset, admin_key) => { if (window.__murasakiOnSubmit) window.__murasakiOnSubmit(); return [text, model, preset, (window.__murasakiAdminKey || admin_key || '')]; }"
btn_event = btn.click(
fn=translate_wrapper,
inputs=[src, m, p, admin_pwd],
outputs=[res, cot, status],
js=sync_admin_key_js,
)
src_event = src.submit(
fn=translate_wrapper,
inputs=[src, m, p, admin_pwd],
outputs=[res, cot, status],
js=sync_admin_key_js,
)
btn_event.then(fn=get_live_stats_line, outputs=[live_stats], show_progress="hidden")
src_event.then(fn=get_live_stats_line, outputs=[live_stats], show_progress="hidden")
demo.queue(status_update_rate=1, default_concurrency_limit=10)
demo.load(fn=get_live_stats_line, outputs=[live_stats], show_progress="hidden")
stats_timer = gr.Timer(value=60.0)
stats_timer.tick(fn=get_live_stats_line, outputs=[live_stats], show_progress="hidden")
if __name__ == "__main__":
launch_kwargs = {"server_name": "0.0.0.0", "server_port": 7860}
if GRADIO_MAJOR >= 6:
launch_kwargs["theme"] = theme
launch_kwargs["css"] = CSS
demo.launch(**launch_kwargs)