""" 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("""

Murasaki LLM 在线演示

ACGN日中翻译模型

""") 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)