"""Gradio UI for HachimiMT zh→vi translation.""" from __future__ import annotations import atexit import html import os import tempfile import time import unicodedata from datetime import datetime from pathlib import Path os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") import gradio as gr import hardware from hardware import detect_hardware_profile from progress_tracker import finish_progress, reset_progress, set_progress, snapshot from text_preprocess import ( NORMALIZE_AUTO, NORMALIZE_NONE, NORMALIZE_T2S, normalization_message, normalize_chinese_text, ) from honorific_normalize import normalize_honorifics from postprocess_policy import classify_genre, v9_route_for_decision from pronoun_harmonizer_v9 import harmonize_pronouns_v9 from translator import ( DEFAULT_MODEL_KEY, MODELS, Backend, HachimiTranslator, ensure_model_files, is_model_downloaded, ) def _env_float(name: str, default: float, *, min_value: float = 0.0, max_value: float = 60.0) -> float: raw = os.environ.get(name, "").strip() if not raw: return default try: return max(min_value, min(max_value, float(raw))) except ValueError: return default ROOT = Path(__file__).resolve().parent.parent EXPORTS_DIR = ROOT / "exports" PID_FILE = ROOT / ".hachimimt.pid" APP_PORT = 7860 # HF Space tự set SPACE_ID. Khi ở Space: CPU-only (ẩn nút cài GPU/torch), process # do HF quản (bỏ PID file), HF tự lo host/port (không ép 127.0.0.1). App chạy # local KHÔNG đổi gì. IS_HF_SPACE = bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST")) # ⚠️ cpu-basic Space: os.cpu_count() báo số core MÁY CHỦ vật lý (16-32) chứ không # phải 2 vCPU thực được cấp → hardware-detect set ct2_threads/batch quá cao → # OVERSUBSCRIPTION, dịch chậm 10-17×. Ép mức an toàn cho cpu-basic (giống Space # demo cũ: 2 threads) TRƯỚC khi detect_hardware_profile() chạy. User vẫn override # được qua env nếu chạy Space tier mạnh hơn. if IS_HF_SPACE: # "Space fast mode" cho cpu-basic (đo bởi Codex: kéo 16 chunk 17.8s → ~Space cũ). # os.cpu_count() báo core MÁY CHỦ (16-32) ≠ 2 vCPU thực → tránh oversubscription # + dùng cấu hình batch hợp CPU nhỏ (examples/window=1, như Space demo cũ). Set # TRƯỚC detect_hardware_profile(). KHÔNG ảnh hưởng bản local (gate IS_HF_SPACE). os.environ.setdefault("HACHIMIMT_THREADS", "2") os.environ.setdefault("HACHIMIMT_TOKENIZE_WORKERS", "2") os.environ.setdefault("HACHIMIMT_BATCH_SIZE", "8") os.environ.setdefault("HACHIMIMT_INTER_THREADS", "1") os.environ.setdefault("HACHIMIMT_CT2_BATCH_TYPE", "examples") os.environ.setdefault("HACHIMIMT_CT2_WINDOW_MULTIPLIER", "1") os.environ.setdefault("HACHIMIMT_PROGRESS_SECONDS", "1.0") # Package bản chạy-máy (GPU/offline/file lớn) — .zip đặt NGAY trong Space repo # (public, không cần đăng nhập; repo GitHub đang private nên không link tới đó). # Banner + hướng dẫn cài CHỈ hiện trên Space (local thì đang chạy local rồi → thừa). LOCAL_ZIP_URL = "https://huggingface.co/spaces/ngocdang83/HachimiMT-demo/resolve/main/hachimimt-local.zip" # Notebook Colab đặt ở repo GitHub PUBLIC riêng (Colab chỉ import được từ GitHub/ # Drive, KHÔNG từ HF Space URL — đã test). Chạy app trên CPU/GPU free của Google. COLAB_URL = "https://colab.research.google.com/github/ngocdang8311/hachimimt-colab/blob/master/HachimiMT_Colab.ipynb" # Kaggle import-from-GitHub: kernels/welcome?src= → MỞ Kaggle tạo # notebook từ repo public (như nút Colab). KHÔNG dùng link resolve .ipynb của Space — # cái đó chỉ TẢI file JSON thô (text/plain), không mở Kaggle → gây hiểu nhầm. KAGGLE_NOTEBOOK_URL = "https://www.kaggle.com/kernels/welcome?src=https://github.com/ngocdang8311/hachimimt-colab/blob/master/HachimiMT_Kaggle.ipynb" MAX_TABLE_ROWS = 300 # Số ký tự tối đa hiển thị trong ô "Bản dịch đầy đủ" (file xuất .txt vẫn đầy đủ). FULL_OUTPUT_DISPLAY_LIMIT = 50_000 # Trên Space cpu-basic (2 vCPU dùng chung, công khai): cắt văn bản đầu vào để 1 # người tải nguyên bộ truyện không giữ CPU/RAM quá lâu. Bản dài → Colab/cài máy. # Local/Colab KHÔNG giới hạn (gate trong cap_input_for_space). SPACE_MAX_INPUT_CHARS = 150_000 PROGRESS_UPDATE_SECONDS = _env_float("HACHIMIMT_PROGRESS_SECONDS", 0.5, max_value=10.0) TEXT_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "gbk", "big5") LEGACY_TEXT_ENCODINGS = ("gb18030", "gbk", "big5") HW_PROFILE = detect_hardware_profile() translator = HachimiTranslator(HW_PROFILE) EXPORTS_DIR.mkdir(exist_ok=True) def gpu_available_but_idle() -> bool: """Máy CÓ GPU NVIDIA vật lý nhưng app đang chạy CPU (thiếu torch-CUDA). Đây là nhóm nên được mời cài torch để bật GPU (nhanh hơn nhiều lần). Trên HF Space: CPU-only, KHÔNG cho pip install runtime → luôn False (ẩn nút cài GPU + khối gpu-hint; mọi chỗ dùng hàm này tự đúng theo). """ if IS_HF_SPACE: return False return bool(hardware.PHYSICAL_NVIDIA_GPU) and not HW_PROFILE.has_cuda def render_gpu_hint_html() -> str: if not gpu_available_but_idle(): return "" gpu = html.escape(hardware.PHYSICAL_GPU_NAME or "GPU NVIDIA") return ( '
' f"⚡ Phát hiện {gpu} nhưng app đang chạy bằng CPU " "(thiếu thư viện CUDA). Bật GPU sẽ dịch nhanh hơn nhiều lần. " "Bấm nút bên dưới để cài tự động (tải ~2–3 GB, cần ~5 GB ổ trống, một lần)." "
" ) # App khoá ở light mode hoàn toàn bằng CSS: khối ":root, .dark" bên dưới # ánh xạ mọi biến theme sang palette "giấy cũ", nên giao diện đúng bất kể # trình duyệt đang ở light hay dark (system-theme-proof). Không dùng JS ép # theme vì Gradio bỏ qua nó ở đây và CSS đã đủ. # Preload font trong để giảm nhấp nháy khi tải. HEAD_HTML = """ """ CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,500&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;1,7..72,400&family=Noto+Serif+SC:wght@400;600&display=swap'); /* ── Palette "giấy cũ / thư phòng" ─────────────────────────────────── */ :root, .dark { --paper: #f4ecdc; /* nền giấy */ --paper-deep: #e7dac2; /* giấy đậm (đáy gradient) */ --card: #fbf6ea; /* mặt thẻ */ --card-soft: #f7efe0; /* thẻ phụ */ --ink: #221b12; /* mực chính */ --ink-soft: #514537; /* mực nhạt */ --muted: #8a7a64; /* chú thích */ --accent: #b03a26; /* son / chu sa */ --accent-2: #c8553a; /* son sáng */ --gold: #9c7b3f; /* nhũ vàng cũ */ --border: #d8cbb1; /* viền giấy */ --border-soft:#e6dcc8; --shadow: rgba(60, 44, 24, 0.10); /* Ánh xạ vào biến chuẩn của Gradio để mọi widget theo theme này (đây là gốc rễ của bug cũ: dark-mode còn sót làm chữ trắng/nền tối). */ --body-background-fill: transparent; --body-text-color: var(--ink); --body-text-color-subdued: var(--muted); --background-fill-primary: var(--card); --background-fill-secondary: var(--card-soft); --block-background-fill: var(--card); --block-label-background-fill: transparent; --block-border-color: var(--border); --block-label-text-color: var(--ink-soft); --block-title-text-color: var(--ink-soft); --border-color-primary: var(--border); --border-color-accent: var(--accent); --input-background-fill: #fffdf7; --input-background-fill-focus: #fffefb; --input-border-color: var(--border); --input-border-color-focus: var(--accent); --input-placeholder-color: #b6a88f; --neutral-950: var(--ink); --color-accent: var(--accent); --color-accent-soft: #efd9c9; --link-text-color: var(--accent); --table-border-color: var(--border); --table-even-background-fill: var(--card); --table-odd-background-fill: var(--card-soft); --button-secondary-background-fill: #efe6d3; --button-secondary-background-fill-hover: #e7dcc4; --button-secondary-text-color: var(--ink); --button-secondary-border-color: var(--border); /* Toast lỗi: dark-mode Gradio đặt nền #0f0e0d (đen) — ép về giấy. */ --error-background-fill: var(--card); --error-border-color: var(--accent); --error-text-color: var(--accent); --color-red-50: #f7e7e2; } /* ── HARD-OVERRIDE dark mode (HF) ────────────────────────────────────── HF Space đặt class `.dark` trên và định nghĩa lại MỘT SỐ biến nền ở selector sâu hơn (vd --block-background-fill, --background-fill-primary) nên override ở :root thua specificity → card/khối con ra nền nâu-đen, chữ mờ. CSS custom property CÓ hỗ trợ !important và nó thắng mọi định nghĩa thường bất kể specificity. App này CHỈ light → ép cứng các biến nền/text về giấy. */ body.dark, .dark, gradio-app.dark { --background-fill-primary: var(--card) !important; --background-fill-secondary: var(--card-soft) !important; --block-background-fill: var(--card) !important; --block-label-background-fill: transparent !important; --body-background-fill: transparent !important; --body-text-color: var(--ink) !important; --body-text-color-subdued: var(--muted) !important; --block-label-text-color: var(--ink-soft) !important; --block-title-text-color: var(--ink-soft) !important; --input-background-fill: #fffdf7 !important; --panel-background-fill: var(--card) !important; --border-color-primary: var(--border) !important; /* nhóm neutral Gradio dùng cho nền/scale tối */ --neutral-950: var(--ink) !important; --neutral-900: #2a2014 !important; --neutral-800: #514537 !important; --color-accent-soft: #efd9c9 !important; --error-background-fill: var(--card) !important; color-scheme: light !important; } /* ── Nền & khung tổng thể ──────────────────────────────────────────── */ /* Phủ nền lên html + body + gradio-app: rộng hết khung và ở dark-mode có nền đen — nếu bỏ sót, hai bên (ngoài container 1180px) và vùng overscroll sẽ lộ màu đen. Đây là nguồn gốc các "dải đen" còn lại. */ html { background: #ecdfc9 !important; } html, body, gradio-app, .gradio-container { background: radial-gradient(1200px 600px at 12% -8%, #fbf4e6 0%, transparent 60%), radial-gradient(1000px 700px at 110% 0%, #efe2cb 0%, transparent 55%), linear-gradient(168deg, #f4ecdc 0%, #ecdfc9 55%, #e4d6bd 100%) !important; color: var(--ink) !important; } gradio-app { display: block; min-height: 100vh; } /* Lớp grain giấy rất nhẹ để bớt phẳng */ .gradio-container::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.5; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); } .gradio-container > * { position: relative; z-index: 1; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; } /* ── Typography ────────────────────────────────────────────────────── */ body, .gradio-container, .prose, button, input, textarea, select, .gr-button { font-family: 'Literata', Georgia, 'Times New Roman', serif !important; } h1, h2, h3, h4, .prose h1, .prose h2, .prose h3 { font-family: 'Fraunces', Georgia, serif !important; color: var(--ink) !important; letter-spacing: -0.01em; } /* ── Header (tiêu đề + triện) ──────────────────────────────────────── */ /* Quan trọng: ép overflow visible + bỏ giới hạn cao của khối Markdown, nếu không Gradio sinh scrollbar con ngay cạnh logo (chữ cao hơn khối vài px → overflow:auto). user-select:none để bỏ select nền xanh chướng. */ #app-header, #app-title { overflow: visible !important; max-height: none !important; user-select: none; background: transparent !important; border: none !important; } #app-header { text-align: center; margin: 0.6rem 0 0.2rem; } #app-title h1 { font-size: clamp(2.4rem, 5vw, 3.4rem) !important; font-weight: 600 !important; margin: 0 !important; line-height: 1.18; padding: 0.08em 0; background: linear-gradient(180deg, #2a2014 0%, #6a3a26 120%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } #app-title h1::after { content: "譯"; /* "Dịch" — con triện đỏ cạnh tiêu đề */ -webkit-text-fill-color: #fff; font-family: 'Noto Serif SC', serif; font-size: 0.42em; font-weight: 600; vertical-align: 0.5em; margin-left: 0.45rem; background: var(--accent); padding: 0.12em 0.18em 0.04em; border-radius: 6px; box-shadow: 0 2px 6px rgba(176,58,38,0.35); } #app-rule { width: 90px; height: 2px; margin: 0.7rem auto 1.3rem; background: linear-gradient(90deg, transparent, var(--gold), transparent); } /* Banner demo (chỉ Space) — mời tải bản local. Khớp palette giấy, viền son nhạt. */ #demo-banner { max-width: 1180px; margin: -0.4rem auto 1.1rem; padding: 10px 18px; background: linear-gradient(180deg, #fbf1e2, #f6ead4); border: 1px solid var(--gold); border-radius: 12px; font-size: 0.92rem; line-height: 1.5; color: var(--ink-soft); text-align: center; box-shadow: 0 1px 3px var(--shadow); } #demo-banner a { color: var(--accent) !important; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; } /* ── Khối / thẻ ────────────────────────────────────────────────────── */ .gr-group, .block, .gr-box { border-radius: 14px !important; border-color: var(--border) !important; } #settings-card { background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 16px !important; box-shadow: 0 1px 2px var(--shadow), 0 10px 30px -22px var(--shadow); padding: 6px 16px 14px !important; } .section-label, .section-label p { font-family: 'Fraunces', Georgia, serif !important; font-size: 0.78rem !important; font-weight: 600 !important; letter-spacing: 0.14em; text-transform: uppercase; color: var(--gold) !important; margin: 0.2rem 0 0.1rem !important; } /* Nhãn các control: bỏ kiểu "pill cam" nặng nề của Gradio, chuyển thành nhãn chữ nhỏ thanh thoát. */ .block > label > span, span[data-testid="block-info"], .gr-form > div > label > span { background: transparent !important; color: var(--ink-soft) !important; font-family: 'Literata', serif !important; font-weight: 600 !important; font-size: 0.86rem !important; letter-spacing: 0.01em; padding: 0 0 2px 0 !important; } /* ── Input / textarea ──────────────────────────────────────────────── */ input, textarea, .gr-input, .wrap.svelte-1ipelgc { background: #fffdf7 !important; color: var(--ink) !important; border-color: var(--border) !important; } textarea::placeholder, input::placeholder { color: #b6a88f !important; } textarea:focus, input:focus { border-color: var(--accent) !important; } .source-input textarea, .file-preview textarea, #full-output textarea { font-family: 'Literata', Georgia, serif !important; font-size: 1.02rem !important; line-height: 1.75 !important; } /* ── Nút ───────────────────────────────────────────────────────────── */ button.primary, .gr-button-primary, button[variant="primary"] { background: linear-gradient(180deg, var(--accent-2), var(--accent)) !important; border: 1px solid #93311f !important; color: #fff7ef !important; font-weight: 600 !important; letter-spacing: 0.01em; box-shadow: 0 2px 8px -2px rgba(176,58,38,0.5) !important; transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease !important; } button.primary:hover, .gr-button-primary:hover { filter: brightness(1.05); transform: translateY(-1px); box-shadow: 0 6px 16px -4px rgba(176,58,38,0.55) !important; } button.secondary, .gr-button-secondary { background: #efe6d3 !important; border: 1px solid var(--border) !important; color: var(--ink) !important; font-weight: 600 !important; } button.secondary:hover, .gr-button-secondary:hover { background: #e7dcc4 !important; } /* ── Tabs ──────────────────────────────────────────────────────────── */ /* Bỏ nền/viền/padding thừa của khối bao Tabs để thanh tab liền mạch với nền giấy thay vì nằm trong một thẻ riêng. */ .tabs, .tab-wrapper, .tabitem { background: transparent !important; border: none !important; box-shadow: none !important; padding-top: 0 !important; } .tab-nav { border-bottom: 1px solid var(--border) !important; } .tab-nav button { font-family: 'Fraunces', Georgia, serif !important; font-size: 1rem !important; color: var(--muted) !important; } .tab-nav button.selected { color: var(--accent) !important; border-bottom: 2px solid var(--accent) !important; } /* ── Radio / Checkbox ──────────────────────────────────────────────── */ /* Mặc định (chưa chọn): nền giấy, chữ mực — ghi đè màu tối còn sót của dark-mode Gradio (đây là chỗ widget bị "đen" trên nền sáng). */ .gr-check-radio label, fieldset label:has(input[type="radio"]), fieldset label:has(input[type="checkbox"]) { background: #f1e7d4 !important; color: var(--ink) !important; border: 1px solid var(--border) !important; border-radius: 9px !important; transition: background 0.12s ease, border-color 0.12s ease !important; } .gr-check-radio label:hover, fieldset label:has(input[type="radio"]):hover, fieldset label:has(input[type="checkbox"]):hover { background: #ebe0ca !important; border-color: var(--gold) !important; } /* Đang chọn: nền son nhạt, viền son */ .gr-check-radio label.selected, label.selected, fieldset label:has(input:checked) { background: var(--color-accent-soft) !important; border-color: var(--accent) !important; color: var(--ink) !important; } /* Chấm radio / ô checkbox khi tick */ input[type="radio"]:checked, input[type="checkbox"]:checked { background-color: var(--accent) !important; border-color: var(--accent) !important; } /* ── Accordion "Tuỳ chọn chuẩn hóa xưng hô" (collapse, mặc định đóng) ─── 3 checkbox cùng chủ đề, tách hẳn khỏi card cấu hình dịch. Label gọn 1 dòng, bỏ info dài của Gradio (gây ngộp) → 1 dòng hint chung bên dưới. */ #honorific-accordion label { font-weight: 500 !important; white-space: nowrap; } #honorific-accordion .gr-check-radio span, #honorific-accordion label > span:not(:first-child) { font-size: 0.92rem !important; } /* Gradio render info trong block-info/.info — ẩn để khỏi chiếm 3-4 dòng/ô */ #honorific-accordion div[data-testid="block-info"], #honorific-accordion .info { display: none !important; } /* dòng hint chung: nhỏ, italic, mực nhạt */ #honorific-accordion .honorific-hint, #honorific-accordion .honorific-hint p { font-size: 0.82rem !important; font-style: italic; color: var(--muted) !important; margin: 10px 2px 2px !important; line-height: 1.45 !important; } /* chip "thử nghiệm": son nhạt trên giấy, không in nghiêng */ #honorific-accordion .honorific-hint .exp-badge { display: inline-block; font-style: normal; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.02em; color: var(--accent); background: rgba(176, 58, 38, 0.10); border: 1px solid rgba(176, 58, 38, 0.28); border-radius: 999px; padding: 1px 9px; margin-right: 6px; vertical-align: 1px; white-space: nowrap; } /* ── Slider ────────────────────────────────────────────────────────── */ input[type="range"]::-webkit-slider-thumb { background: var(--accent) !important; } .gr-slider .head, .slider_input_container .slider { accent-color: var(--accent) !important; } /* Beam: chỉ chọn 1–4 → ẩn ô nhập số + nút reset (thừa, gây lệch), giữ thanh kéo. Scoped #beam-slider để KHÔNG ảnh hưởng ô số của slider Batch size (cần nhập tay). Gradio 6 đặt nút reset aria-label="Reset to default value". */ #beam-slider input[type="number"], #beam-slider .number-input, #beam-slider button[aria-label*="eset"], #beam-slider button[title*="eset"] { display: none !important; } /* ── Thanh tiến trình tuỳ biến ─────────────────────────────────────── */ .progress-wrap { border: 1px solid var(--border); background: var(--card); border-radius: 14px; padding: 14px 18px; box-shadow: 0 1px 2px var(--shadow); } .progress-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 9px; } .progress-pct { font-family: 'Fraunces', Georgia, serif; font-size: 1.15rem; font-weight: 600; color: var(--accent); } .progress-state { font-size: 0.85rem; color: var(--muted); font-style: italic; } .progress-track { height: 9px; border-radius: 999px; background: #e6dac2; overflow: hidden; box-shadow: inset 0 1px 2px rgba(90,66,30,0.18); } .progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--gold)); background-size: 200% 100%; transition: width 0.3s ease; } .progress-fill.is-running { animation: progress-shine 1.6s linear infinite; } @keyframes progress-shine { to { background-position: 200% 0; } } .progress-msg { margin-top: 8px; font-size: 0.92rem; color: var(--ink-soft); } /* ── Khung đối chiếu song song (xen kẽ câu Trung · Việt) ────────────── */ #compare-view { max-height: 560px; overflow-y: auto; padding-right: 6px; } /* Thanh cuộn mảnh hợp tông giấy */ #compare-view::-webkit-scrollbar { width: 9px; } #compare-view::-webkit-scrollbar-thumb { background: #d2c3a6; border-radius: 999px; } #compare-view::-webkit-scrollbar-track { background: transparent; } .compare-list { display: flex; flex-direction: column; } /* Lưới 3 cột: số thứ tự · câu Trung · câu Việt. Cột 1fr/1fr tự wrap nên không tràn ngang (lỗi cũ của Dataframe). */ .cmp-head, .cmp-row { display: grid; grid-template-columns: 2rem 1fr 1fr; gap: 16px; align-items: start; } .cmp-head { position: sticky; top: 0; z-index: 2; padding: 4px 8px 8px; background: linear-gradient(180deg, var(--card) 70%, rgba(251,246,234,0)); border-bottom: 1px solid var(--border); } .cmp-col { font-family: 'Fraunces', Georgia, serif; font-size: 0.82rem; font-weight: 600; letter-spacing: 0.04em; color: var(--gold); text-transform: uppercase; } .cmp-row { padding: 13px 8px 14px; border-bottom: 1px solid var(--border-soft); border-radius: 10px; transition: background 0.12s ease; } .cmp-row:last-child { border-bottom: none; } /* Linked highlight: rê vào hàng → cả ô Trung và ô Việt cùng sáng */ .cmp-row:hover { background: #f4e8d2; } .cmp-num { grid-column: 1; min-width: 1.8rem; height: 1.8rem; display: inline-flex; align-items: center; justify-content: center; font-family: 'Fraunces', Georgia, serif; font-size: 0.82rem; font-weight: 600; color: var(--accent); background: #f3e7d3; border: 1px solid var(--border); border-radius: 999px; } .cmp-row:hover .cmp-num { background: var(--accent); color: #fff7ef; border-color: var(--accent); } .cmp-zh { grid-column: 2; min-width: 0; font-family: 'Noto Serif SC', serif; font-size: 1.0rem; line-height: 1.7; color: var(--ink-soft); margin: 0; } .cmp-vi { grid-column: 3; min-width: 0; font-family: 'Literata', Georgia, serif; font-size: 1.02rem; line-height: 1.7; color: var(--ink); margin: 0; } .cmp-note { padding: 12px 6px 2px; font-size: 0.88rem; font-style: italic; color: var(--muted); } .compare-empty { padding: 22px 8px; text-align: center; color: var(--muted); font-style: italic; font-size: 0.95rem; } /* ── Accordion ─────────────────────────────────────────────────────── */ /* Header accordion mặc định lấy --body-text-color (trắng ở dark-mode) → gần như vô hình trên nền giấy. Ép chữ mực + nền giấy cho rõ. */ .gr-accordion { background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 12px !important; } .gr-accordion .label-wrap, .gr-accordion .label-wrap span, .gr-accordion button.label-wrap { color: var(--ink-soft) !important; font-family: 'Fraunces', Georgia, serif !important; font-weight: 600 !important; } .gr-accordion .label-wrap:hover, .gr-accordion .label-wrap:hover span { color: var(--accent) !important; } .gr-accordion .label-wrap .icon, .gr-accordion .label-wrap svg { color: var(--accent) !important; } /* ── Khối thông tin máy / engine ───────────────────────────────────── */ .info-card { background: var(--card-soft) !important; border: 1px solid var(--border-soft) !important; border-radius: 12px !important; padding: 10px 16px !important; font-size: 0.9rem; color: var(--ink-soft) !important; } .info-card p { margin: 0.2rem 0 !important; color: var(--ink-soft) !important; } .info-card strong, .info-card code { color: var(--ink) !important; } code, .prose code { background: #efe4cf !important; color: var(--accent) !important; border-radius: 5px; padding: 0.05em 0.4em; } /* ── Toast thông báo / lỗi ─────────────────────────────────────────── */ /* Ghi đè trực tiếp (biến cấp .dark của Gradio thắng được khai báo :root), đảm bảo toast luôn nền giấy + chữ son, không bị nền đen của dark-mode. */ .toast-body { background: var(--card) !important; border: 1px solid var(--border) !important; color: var(--ink) !important; box-shadow: 0 8px 28px -8px var(--shadow) !important; } .toast-body.error, .toast-body.warning, .toast-body.info { border-left: 4px solid var(--accent) !important; } .toast-title, .toast-text, .toast-body * { color: var(--ink) !important; } .toast-icon, .toast-body svg { color: var(--accent) !important; fill: var(--accent) !important; } .toast-close { color: var(--muted) !important; } /* Thanh đếm ngược của toast */ .timer { background: var(--accent) !important; } /* ── Banner kết quả ────────────────────────────────────────────────── */ /* Rỗng (chưa dịch) → ẩn hẳn, tránh thẻ kem trống chiếm chỗ. */ #result-summary:not(:has(p)):not(:has(li)) { display: none !important; } #result-summary:has(p), #result-summary:has(li) { background: linear-gradient(180deg, #f7ece0, #f2e3d2) !important; border: 1px solid var(--border) !important; border-left: 4px solid var(--accent) !important; border-radius: 12px !important; padding: 12px 18px !important; margin: 4px 0 6px !important; } #result-summary p { margin: 0.15rem 0 !important; color: var(--ink) !important; } /* ── Badge trạng thái tải model ────────────────────────────────────── */ #model-badge { margin: -0.2rem 0 0.1rem; } .model-meta { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .model-hf-link { font-size: 0.8rem; color: var(--accent) !important; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.15s; } .model-hf-link:hover { border-bottom-color: var(--accent); } .model-badge { display: inline-block; font-size: 0.82rem; font-weight: 500; padding: 0.18em 0.7em; border-radius: 999px; border: 1px solid var(--border); letter-spacing: 0.01em; } .model-badge.ready { background: #e8efe0; color: #3f5a32; border-color: #c2d2b0; } .model-badge.pending { background: #f6ecd6; color: var(--gold); border-color: #ddcaa5; } /* ── Gợi ý bật GPU (chỉ hiện khi có GPU NVIDIA nhưng đang chạy CPU) ──── */ #gpu-hint-box { background: linear-gradient(180deg, #f3ece2, #efe6d4) !important; border: 1px solid var(--border) !important; border-left: 4px solid var(--gold) !important; border-radius: 12px !important; padding: 12px 16px !important; margin: 4px 0 2px !important; } .gpu-hint { color: var(--ink-soft); font-size: 0.9rem; line-height: 1.5; margin-bottom: 0.5rem; } .gpu-hint b { color: var(--ink); } footer { display: none !important; } """ def write_pid_file() -> None: PID_FILE.write_text(str(os.getpid()), encoding="utf-8") def remove_pid_file() -> None: PID_FILE.unlink(missing_ok=True) def resolve_batch_size(auto_batch: bool, manual_batch: float) -> int: if auto_batch: profile = detect_hardware_profile() translator.apply_hardware_profile(profile) return profile.batch_size batch = int(manual_batch) translator.set_batch_size(batch) return batch def ensure_model(model_key: str, backend: str, beam_size: float) -> str: status = translator.load(model_key, backend=backend) beam = HachimiTranslator.clamp_beam(beam_size) return f"{status} · beam={beam} · batch={translator.batch_size}" def on_auto_batch_toggle(auto_batch: bool) -> dict: profile = detect_hardware_profile() return gr.update(value=profile.batch_size, interactive=not auto_batch) def _model_hf_link(config) -> str: url = html.escape(f"https://huggingface.co/{config.model_id}", quote=True) return ( f'' "↗ Trang Hugging Face" ) def render_model_badge(model_key: str, backend: str) -> str: """Badge cho biết model (theo engine đang chọn) đã tải sẵn hay sẽ phải tải, kèm link tới trang Hugging Face của model.""" if model_key not in MODELS: return "" config = MODELS[model_key] link = _model_hf_link(config) if is_model_downloaded(model_key, backend): badge = '✓ Đã tải — dịch được ngay' return f'
{badge}{link}
' if backend == Backend.CT2.value and config.ct2_size_mb: size = f" (~{config.ct2_size_mb} MB)" elif backend != Backend.CT2.value: size = " (bản PyTorch, nặng hơn)" else: size = "" badge = ( f'⬇ Chưa có{size} — ' "sẽ tự tải từ Hugging Face ở lần dịch đầu" ) return f'
{badge}{link}
' def on_model_change(model_key: str, backend: str) -> tuple[float, str]: return float(MODELS[model_key].default_beam), render_model_badge(model_key, backend) def install_gpu_torch_ui(): """Handler nút 'Cài torch để bật GPU'. Stream log pip realtime vào textbox. Yield TỪNG dòng pip ngay khi có, nên người dùng thấy tiến trình tải/cài liên tục (không bị 'đứng hình' như khi gọi hàm blocking rồi mới in một lần).""" if not gpu_available_but_idle(): yield gr.update(), "Không cần cài: máy không có GPU NVIDIA đang rảnh." return from gpu_setup import ( _stream_pip, choose_cuda_channel, torch_install_command, verify_ct2_cuda, verify_torch_cuda, ) channel = choose_cuda_channel(hardware.DRIVER_CUDA_VERSION) if channel is None: yield gr.update(), ( "Driver NVIDIA quá cũ so với các bản torch CUDA hiện có. " "Hãy cập nhật driver rồi thử lại." ) return logs = [ f"Cài torch CUDA ({channel}) — tải ~2–3 GB, cần ~5 GB ổ trống. " "Vui lòng đợi và ĐỪNG tắt app…", ] yield gr.update(interactive=False, value="Đang cài… đừng tắt app"), "\n".join(logs) exit_code = None for line in _stream_pip(torch_install_command(channel)): if line.startswith("__EXIT__:"): exit_code = int(line.split(":", 1)[1]) continue if line: logs.append(line) # Giữ textbox gọn: chỉ hiện 200 dòng cuối. yield gr.update(interactive=False), "\n".join(logs[-200:]) if exit_code != 0: logs.append("") logs.append(f"❌ Cài thất bại (mã {exit_code}). Kiểm tra mạng/đĩa hoặc cài thủ công (README).") yield ( gr.update(interactive=True, value="Thử cài lại"), "\n".join(logs[-200:]), ) return # pip exit 0 chưa chắc có CUDA (vd đã có torch-CPU). Xác minh thật. logs.append("") logs.append("Đang kiểm tra torch và CTranslate2 có nhận GPU không…") yield gr.update(interactive=False), "\n".join(logs[-200:]) torch_ok, verify_msg = verify_torch_cuda() logs.append(("✅ " if torch_ok else "❌ ") + verify_msg) ct2_ok = False if torch_ok: ct2_ok, ct2_msg = verify_ct2_cuda() logs.append(("✅ " if ct2_ok else "❌ ") + ct2_msg) ok = torch_ok and ct2_ok if ok: logs.append("Hãy TẮT và MỞ LẠI app (stop.bat rồi start.bat) để bật GPU.") yield ( gr.update( interactive=not ok, value="Cài xong — khởi động lại app" if ok else "Thử cài lại", ), "\n".join(logs[-200:]), ) def _engine_hint_text(backend: str) -> str: if backend == Backend.CT2.value: return "**Engine:** CTranslate2 — khuyên dùng, tận dụng GPU + batch tốt, nhanh nhất." return ( "**Engine:** PyTorch — đã hỗ trợ batch GPU, nhưng vẫn chậm hơn CT2. " "GPU % thấp là bình thường với model ~60M." ) def on_backend_change(backend: str, model_key: str) -> tuple[str, str]: """Đổi engine → cập nhật cả gợi ý engine lẫn badge trạng thái tải của model.""" return _engine_hint_text(backend), render_model_badge(model_key, backend) def render_progress_html(pct: float, message: str, running: bool) -> str: status = "Đang dịch…" if running else "Sẵn sàng" safe_message = html.escape(message, quote=True) safe_status = html.escape(status, quote=True) running_cls = " is-running" if running else "" return f"""
{pct:.0f}% {safe_status}
{safe_message}
""" def poll_progress_ui() -> str: state = snapshot() return render_progress_html(state.pct, state.message, state.running) EMPTY_COMPARE_HTML = ( '
Dịch xong, bản gốc và bản dịch sẽ hiện đối chiếu ở đây ' "— từng câu một, cuộn dọc để đọc soát.
" ) def render_compare_html(rows: list[tuple[int, str, str]]) -> str: """Render đối chiếu song song 2 cột (Trung | Việt), mỗi chunk một hàng. Mỗi hàng là một chunk (câu hoặc cả đoạn, tùy chế độ chia) đã khớp sẵn từ backend → rê chuột vào một hàng thì cả ô Trung lẫn ô Việt cùng sáng (linked highlight kiểu Google Dịch), chỉ bằng CSS :hover vì đây là markup của ta trong gr.HTML.""" if not rows: return EMPTY_COMPARE_HTML display = rows[:MAX_TABLE_ROWS] items = [ '
' '' '
Tiếng Trung
' '
Tiếng Việt
' "
" ] for idx, zh, vi in display: safe_zh = html.escape(zh, quote=True) safe_vi = html.escape(vi, quote=True) items.append( f'
' f'{idx}' f'

{safe_zh}

' f'

{safe_vi}

' f"
" ) note = "" if len(rows) > MAX_TABLE_ROWS: note = ( f'
Hiển thị {MAX_TABLE_ROWS}/{len(rows)} đoạn đầu. ' "Xem bản dịch đầy đủ ở khối bên dưới.
" ) return f'
{"".join(items)}{note}
' def _decoded_text_score(text: str) -> int: """Lower is better. Helps avoid Big5 bytes decoded as valid GB18030 junk.""" score = 0 for char in text: if char == "\ufffd": score += 100 continue if char in "\n\r\t": continue category = unicodedata.category(char) if category.startswith("C"): score += 20 continue name = unicodedata.name(char, "") if ( "HIRAGANA" in name or "KATAKANA" in name or "BOPOMOFO" in name or "HANGUL" in name ): score += 6 return score def _decode_text_bytes(data: bytes) -> str: for encoding in ("utf-8-sig", "utf-8"): try: return data.decode(encoding) except UnicodeDecodeError: continue candidates: list[tuple[int, int, str]] = [] for index, encoding in enumerate(LEGACY_TEXT_ENCODINGS): try: decoded = data.decode(encoding) except UnicodeDecodeError: continue candidates.append((_decoded_text_score(decoded), index, decoded)) if candidates: return min(candidates, key=lambda item: (item[0], item[1]))[2] return data.decode("utf-8", errors="replace") def read_text_file(path: Path, *, max_chars: int | None = None) -> str: text = _decode_text_bytes(path.read_bytes()) return text[:max_chars] if max_chars is not None else text def cap_input_for_space(source: str) -> tuple[str, str]: """Trên Space (CPU dùng chung, công khai): cắt văn bản dài để tránh 1 người giữ tài nguyên quá lâu. Trả (văn_bản_đã_cắt, ghi_chú). Local/Colab: trả nguyên văn + ghi chú rỗng.""" if not IS_HF_SPACE or len(source) <= SPACE_MAX_INPUT_CHARS: return source, "" capped = source[:SPACE_MAX_INPUT_CHARS] notice = ( f" ⚠️ Bản Space (CPU dùng chung) giới hạn ~{SPACE_MAX_INPUT_CHARS:,} ký tự/lần " f"— đã dịch phần đầu ({SPACE_MAX_INPUT_CHARS:,}/{len(source):,}). Muốn dịch trọn " f"bộ truyện dài, hãy dùng **Google Colab/Kaggle (GPU)** hoặc **bản cài máy** ở phần " f"\"🚀 Dùng nhanh/mạnh hơn\" bên trên (không giới hạn, nhanh hơn nhiều)." ) return capped, notice def _exception_message(exc: Exception) -> str: return str(exc).strip() or exc.__class__.__name__ def export_translation(full_text: str, filename_stem: str) -> str | None: if not full_text.strip(): raise gr.Error("Chưa có bản dịch để xuất.") safe_stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in filename_stem) or "translation" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = EXPORTS_DIR / f"{safe_stem}_vi_{timestamp}.txt" out_path.write_text(full_text, encoding="utf-8") return str(out_path) def _format_duration(seconds: float) -> str: """Định dạng thời gian gọn: '12,3 giây' hoặc '2 phút 5 giây'.""" if seconds < 60: return f"{seconds:.1f} giây".replace(".", ",") minutes = int(seconds // 60) rem = int(round(seconds - minutes * 60)) if rem == 0: return f"{minutes} phút" return f"{minutes} phút {rem} giây" def _clamp_full_text(full_text: str) -> str: """Giới hạn text hiển thị trong ô 'Bản dịch đầy đủ' để tránh lag với file lớn. Textbox chứa vài MB (truyện hàng chục nghìn câu) làm trình duyệt giật khi render/cuộn. File xuất .txt vẫn ĐẦY ĐỦ — đây chỉ cắt phần HIỂN THỊ. User đọc trọn bộ bằng nút tải file bên dưới. """ if len(full_text) <= FULL_OUTPUT_DISPLAY_LIMIT: return full_text head = full_text[:FULL_OUTPUT_DISPLAY_LIMIT].rsplit("\n", 1)[0] omitted = len(full_text) - len(head) return ( f"{head}\n\n" f"────────────────────\n" f"[Đã ẩn ~{omitted:,} ký tự còn lại để tránh lag. " f"Bản dịch ĐẦY ĐỦ đã lưu — bấm “Xuất bản dịch .txt” / tải file bên dưới.]" ).replace(",", ".") def _build_results( rows: list[tuple[int, str, str]], full_text: str, status: str, summary: str, download_path: str | None, ) -> tuple: return ( render_compare_html(rows), _clamp_full_text(full_text), status, download_path, summary, full_text, # bản đầy đủ → gr.State cho nút Xuất .txt ) def apply_postprocess_rows( rows: list[tuple[int, str, str]], *, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, ) -> tuple[list[tuple[int, str, str]], str, dict]: raw_rows = list(rows) raw_full_text = "\n".join(vi for _, _, vi in raw_rows) try: return _apply_postprocess_rows( raw_rows, honorific_kinship=honorific_kinship, honorific_pronouns=honorific_pronouns, pronoun_harmonizer_v9=pronoun_harmonizer_v9, ) except Exception as exc: return raw_rows, raw_full_text, { "genre_decision": None, "honorific_changed": 0, "honorific_pronouns_effective": False, "honorific_kinship_effective": False, "pronoun_report": {}, "warning": _exception_message(exc), "fallback": "raw", } def _policy_rows(rows: list[tuple[int, str, str]]) -> list[tuple[int, str, str]]: return [ (index, normalize_chinese_text(chunk_zh, NORMALIZE_T2S), translated_vi) for index, chunk_zh, translated_vi in rows ] def _apply_postprocess_rows( rows: list[tuple[int, str, str]], *, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, ) -> tuple[list[tuple[int, str, str]], str, dict]: policy_rows = _policy_rows(rows) genre_decision = classify_genre(policy_rows) if policy_rows else None honorific_changed = 0 pronoun_report = {} honorific_pronouns_effective = bool(honorific_pronouns and genre_decision and genre_decision.is_classical) honorific_kinship_effective = bool(honorific_kinship) honorific_on_effective = honorific_kinship_effective or honorific_pronouns_effective if honorific_on_effective and rows: new_rows = [] for (index, chunk_zh, translated_vi), (_, policy_zh, _) in zip(rows, policy_rows): fixed = normalize_honorifics( policy_zh, translated_vi, apply_kinship=honorific_kinship_effective, apply_pronouns=honorific_pronouns_effective, classical_context=genre_decision.is_classical if genre_decision else None, ) if fixed != translated_vi: honorific_changed += 1 new_rows.append((index, chunk_zh, fixed)) rows = new_rows if pronoun_harmonizer_v9 and rows: v9_policy_rows = [ (index, normalize_chinese_text(chunk_zh, NORMALIZE_T2S), translated_vi) for index, chunk_zh, translated_vi in rows ] v9_rows, pronoun_report = harmonize_pronouns_v9( v9_policy_rows, route=v9_route_for_decision(genre_decision) if genre_decision else "unknown_copy_guard", ) if len(v9_rows) != len(rows): raise RuntimeError(f"V9 row count mismatch: expected {len(rows)}, got {len(v9_rows)}") for (original_index, _, _), (fixed_index, _, _) in zip(rows, v9_rows): if original_index != fixed_index: raise RuntimeError(f"V9 row index mismatch: {original_index} != {fixed_index}") rows = [ (index, original_zh, fixed_vi) for (index, original_zh, _), (_, _, fixed_vi) in zip(rows, v9_rows) ] full_text = "\n".join(vi for _, _, vi in rows) return rows, full_text, { "genre_decision": genre_decision, "honorific_changed": honorific_changed, "honorific_pronouns_effective": honorific_pronouns_effective, "honorific_kinship_effective": honorific_kinship_effective, "pronoun_report": pronoun_report, } def _translate_run( source: str, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, *, filename_stem: str, summary_prefix: str, progress: gr.Progress = gr.Progress(), ) -> tuple: try: source, space_cap_notice = cap_input_for_space(source) original_source = source source = normalize_chinese_text(source, normalize_mode) normalize_msg = normalization_message(original_source, source, normalize_mode) honorific_kinship = bool(honorific_kinship) honorific_pronouns = bool(honorific_pronouns) honorific_on = honorific_kinship or honorific_pronouns pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9) if is_model_downloaded(model_key, backend): load_msg = "Đang nạp model..." else: label = MODELS[model_key].label if model_key in MODELS else model_key load_msg = f"Đang tải model {label} từ Hugging Face (lần đầu, vui lòng đợi)..." set_progress(0, load_msg) progress(0, desc=load_msg) resolve_batch_size(auto_batch, manual_batch) status = ensure_model(model_key, backend, beam_size) set_progress(2, f"{normalize_msg} Đang chia chunk...") progress(0.02, desc="Đang chia chunk...") rows: list[tuple[int, str, str]] = [] full_text = "" last_progress_update = 0.0 translate_start = time.perf_counter() for done, total, message, result_rows, result_text in translator.translate_text_iter( source, chunk_mode=chunk_mode, beam_size=int(beam_size), ): if result_rows is not None and result_text is not None: rows = result_rows full_text = result_text continue now = time.perf_counter() should_update_progress = ( done == 0 or done == total or now - last_progress_update >= PROGRESS_UPDATE_SECONDS ) if not should_update_progress: continue pct = round(done / max(total, 1) * 100, 1) detail = f"{message} ({pct}%)" set_progress(pct, detail) progress(done / max(total, 1), desc=message) last_progress_update = now translate_seconds = time.perf_counter() - translate_start rows, full_text, postprocess_report = apply_postprocess_rows( rows, honorific_kinship=honorific_kinship, honorific_pronouns=honorific_pronouns, pronoun_harmonizer_v9=pronoun_harmonizer_v9, ) genre_decision = postprocess_report.get("genre_decision") honorific_changed = int(postprocess_report.get("honorific_changed") or 0) honorific_pronouns_effective = bool(postprocess_report.get("honorific_pronouns_effective")) pronoun_report = postprocess_report.get("pronoun_report") or {} postprocess_warning = str(postprocess_report.get("warning") or "") # KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh # khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ # vẫn nằm trong ô + full_text_state để xuất khi cần. download_path = None duration = _format_duration(translate_seconds) rate = len(rows) / translate_seconds if translate_seconds > 0 else 0.0 time_note = f"⏱ {duration}" if rate >= 1: time_note += f" ({rate:.0f} chunk/giây)" honorific_note = "" if honorific_on: parts = [] if honorific_kinship: parts.append("thân tộc") if honorific_pronouns: parts.append("đại từ" if honorific_pronouns_effective else "đại từ bỏ qua") honorific_note = (f" Đã chuẩn hóa xưng hô ({' + '.join(parts)}): " f"{honorific_changed} chunk chỉnh.") pronoun_note = "" if pronoun_harmonizer_v9: pronoun_note = ( " Ổn định ngôi xưng V9: " f"{pronoun_report.get('changed_rows', 0)} chunk chỉnh, " f"route `{pronoun_report.get('route', 'n/a')}`." ) route_note = f" Route hậu kỳ `{genre_decision.route}`." if genre_decision and (honorific_on or pronoun_harmonizer_v9) else "" warning_note = f" Hậu kỳ lỗi, đã giữ bản dịch thô: {postprocess_warning}." if postprocess_warning else "" summary = f"{summary_prefix} **{len(rows)}** chunk · {time_note}. {normalize_msg}{route_note}{honorific_note}{pronoun_note}{warning_note}{space_cap_notice}" status = f"{status} · {time_note}" finish_progress(f"Hoàn tất — {len(rows)} chunk trong {duration} (100%)") progress(1.0, desc="Hoàn tất") return _build_results(rows, full_text, status, summary, download_path) except Exception as exc: reset_progress(f"Lỗi: {_exception_message(exc)}") raise def translate_text_ui( source: str, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, progress: gr.Progress = gr.Progress(), ) -> tuple: if not source.strip(): raise gr.Error("Nhập văn bản tiếng Trung cần dịch.") return _translate_run( source, model_key, backend, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, filename_stem="hachimimt", summary_prefix="Đã dịch", progress=progress, ) def translate_file_ui( file_obj, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, progress: gr.Progress = gr.Progress(), ) -> tuple: if file_obj is None: raise gr.Error("Chọn file .txt cần dịch.") path = Path(file_obj) if path.suffix.lower() != ".txt": raise gr.Error("Chỉ hỗ trợ file .txt") source = read_text_file(path) if not source.strip(): raise gr.Error("File trống.") return _translate_run( source, model_key, backend, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, filename_stem=path.stem, summary_prefix=f"Đã dịch từ `{path.name}` —", progress=progress, ) def build_ui() -> gr.Blocks: model_choices = [(cfg.label, key) for key, cfg in MODELS.items()] with gr.Blocks(title="HachimiMT — Dịch Trung Việt") as demo: # ── Header ──────────────────────────────────────────────────── with gr.Column(elem_id="app-header"): gr.Markdown("# HachimiMT", elem_id="app-title") gr.HTML('
') # ── Banner demo (chỉ trên Space) ────────────────────────────── if IS_HF_SPACE: gr.HTML( '
' 'Đây là bản demo chạy CPU — phù hợp dịch thử đoạn ngắn. ' 'Cần GPU, dịch file/chương dài hay chạy offline? ' f'☁️ chạy trên Google Colab ' f'/ Kaggle T4×2 ' f'hoặc ⬇ tải bản cài máy ' '(chi tiết ở mục “Dùng nhanh/mạnh hơn” cuối trang).' "
" ) # ── Khu cấu hình ────────────────────────────────────────────── with gr.Group(elem_id="settings-card"): gr.Markdown("Cấu hình dịch", elem_classes=["section-label"]) with gr.Row(): model_select = gr.Dropdown( choices=model_choices, value="HachimiMT-60", label="Model dịch", scale=3, ) backend_select = gr.Radio( [("CTranslate2 — nhanh", Backend.CT2.value), ("PyTorch", Backend.TRANSFORMERS.value)], value=Backend.CT2.value, label="Engine", scale=3, ) # Space (CPU): default beam=1 (nhanh hơn rõ, demo ưu tiên tốc độ); # local: beam=2 (chất lượng nhỉnh hơn, máy mạnh). User vẫn kéo được. beam_size = gr.Slider(1, 4, value=(1 if IS_HF_SPACE else 2), step=1, label="Beam (1–4)", scale=2, elem_id="beam-slider", info="Cao hơn = dịch kỹ hơn, chậm hơn") chunk_mode = gr.Radio( [("Theo câu", "sentence"), ("Theo đoạn", "paragraph")], value="sentence", label="Chia chunk", scale=2, info="Theo câu: bám sát từng dòng. Theo đoạn: gom dòng cho " "nhiều ngữ cảnh hơn (giữ xuống dòng, ranh giới dòng có thể xê dịch nhẹ)", ) normalize_mode = gr.Dropdown( [ ("Tự động phồn → giản", NORMALIZE_AUTO), ("Ép phồn → giản", NORMALIZE_T2S), ("Giữ nguyên", NORMALIZE_NONE), ], value=NORMALIZE_AUTO, label="Chuẩn hóa chữ Hán", scale=2, ) with gr.Row(): auto_batch = gr.Checkbox(value=True, label="Tự động batch theo CPU/GPU", scale=2) manual_batch = gr.Slider( 4, 128, value=HW_PROFILE.batch_size, step=4, label="Batch size (chunk/lần)", interactive=False, scale=3, ) model_badge = gr.HTML( render_model_badge("HachimiMT-60", Backend.CT2.value), elem_id="model-badge", ) engine_hint = gr.Markdown(_engine_hint_text(Backend.CT2.value)) status = gr.Textbox( label="Trạng thái", value="Sẵn sàng — chọn cấu hình rồi bắt đầu dịch.", interactive=False, elem_classes=["status-box"], ) _gpu_idle = gpu_available_but_idle() with gr.Group(visible=_gpu_idle, elem_id="gpu-hint-box") as gpu_hint_group: gpu_hint = gr.HTML(render_gpu_hint_html()) gpu_install_btn = gr.Button( "⚡ Cài torch để bật GPU (tải ~2–3 GB, cần ~5 GB trống)", variant="primary", ) gpu_install_log = gr.Textbox( label="Tiến trình cài đặt", value="", interactive=False, lines=6, visible=False, elem_classes=["status-box"], ) with gr.Accordion("🧪 Tuỳ chọn chuẩn hóa xưng hô (nâng cao · thử nghiệm)", open=False, elem_id="honorific-accordion"): with gr.Row(): honorific_kinship = gr.Checkbox( value=False, label="Thân tộc (tỷ / muội / ca ca…)", scale=1, ) honorific_pronouns = gr.Checkbox( value=False, label="Đại từ (ngươi / hắn / nàng / ta)", scale=1, ) pronoun_harmonizer_v9 = gr.Checkbox( value=False, label="Ổn định ngôi hiện đại", scale=1, ) gr.Markdown( "🧪 Thử nghiệm Hậu kỳ chỉnh xưng hô " "ngoài model — bản dịch gốc thường đã ổn, đây là lớp tinh chỉnh tuỳ chọn " "nên **mặc định tắt**; bật khi muốn ép phong cách, và nên rà lại kết quả.\n\n" "Ép xưng hô về Hán-Việt khi nguồn có từ tương ứng · **Đại từ** chỉ áp " "khi route cấp chương là văn cổ trang · **Ổn định ngôi hiện đại** chỉ " "rewrite khi route hiện đại, ví dụ thầy/em, mẹ/con, anh/em. Mixed/unknown " "sẽ guard để tránh sửa quá tay.", elem_classes=["honorific-hint"], ) if IS_HF_SPACE: with gr.Accordion("🚀 Dùng nhanh/mạnh hơn — Google Colab/Kaggle (GPU) hoặc cài máy (offline)", open=False): gr.Markdown( f"""Bản Space này chạy **CPU** nên hợp dịch thử. Ba cách dùng nhanh/mạnh hơn: ### ☁️ Cách 1 — Chạy trên Google Colab (CPU/GPU miễn phí, không cài gì) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)]({COLAB_URL}) Bấm nút trên → đăng nhập Google → `Runtime → Run all` → mở link `*.gradio.live` hiện ra để dịch. Muốn nhanh: chọn `Runtime → Change runtime type → T4 GPU` trước (Colab tiếng Việt: `Thời gian chạy → Thay đổi loại thời gian chạy → T4 GPU → Lưu`). ⚠️ Chọn **T4 GPU**, KHÔNG chọn **TPU** — app chỉ chạy GPU NVIDIA/CPU, không dùng được TPU. > ⚡ **Tốc độ trên Colab T4** (đo thật, notebook đã tối ưu window): **~40.000 chữ Hán/giây** > ở beam 1 hoặc **~28.000 chữ/giây** ở beam 2; cả một bộ truyện dài **2,4 triệu chữ > khoảng 1–1,5 phút**. Nhanh hơn CPU nhiều lần. ### ☁️ Cách 2 — Chạy trên Kaggle (T4×2 miễn phí, hợp dịch file dài) [![Open in Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)]({KAGGLE_NOTEBOOK_URL}) Bấm nút trên → đăng nhập Kaggle → bấm **Create / Edit** (Kaggle tạo notebook từ GitHub) → bật `Settings → Accelerator → GPU T4 x2` và `Settings → Internet → on` → `Run All`. Cell cuối hiện link `*.gradio.live` để mở giao diện dịch. Notebook đã bật `HACHIMIMT_AUTO_ALL_GPUS=1` để dùng cả 2 GPU T4 khi Kaggle cấp đủ. > ⚡ **Tốc độ trên T4 x2** (đo mới nhất): **~71.000 chữ Hán/giây** (beam 1) hoặc > **~52.000 chữ Hán/giây** (beam 2) — file **2,84 triệu chữ ~40 giây** ở beam 1, > hoặc **~55 giây** ở beam 2. Nhanh hơn Colab T4 x1 beam 2 khoảng 1,9× trên file dài. ### 💻 Cách 3 — Cài bản chạy trên máy (offline, dịch file lớn) **1. Tải về** — [⬇ Tải bản cài (.zip, ~60 KB)]({LOCAL_ZIP_URL}) rồi giải nén (ra thư mục `hachimimt/`). **2. Windows** (cần [Python 3.10+](https://www.python.org/downloads/)): trong thư mục `hachimimt`, chạy `setup.bat` (cài thư viện + tải model mặc định, một lần) rồi `start.bat` — trình duyệt tự mở `http://127.0.0.1:7860`. **3. macOS / Linux**: `./setup_macos.sh` rồi `./start_macos.sh` (xem `README.md`). - **GPU NVIDIA**: app mặc định chạy CPU; có nút **“Cài torch để bật GPU”** ngay trong app (tải ~2–3 GB, một lần) → nhanh hơn nhiều lần với văn bản dài. - Model (HachimiMT/MoxhiMT) tự tải từ Hugging Face lần đầu, sau đó chạy **offline**. - Bản local mở khoá: chọn 4 model, dịch file `.txt`, không giới hạn CPU như Space.""", elem_classes=["info-card"], ) with gr.Accordion("Thông tin máy & hướng dẫn", open=False): _startup_line = ( "" # Space: không có start.bat / 127.0.0.1 → bỏ dòng vô nghĩa if IS_HF_SPACE else f"\n\n**Khởi động / tắt:** `start.bat` / `stop.bat` · http://127.0.0.1:{APP_PORT}" ) gr.Markdown( f"**Cấu hình tự động:** {HW_PROFILE.summary}\n\n" f"**GPU inference:** {HW_PROFILE.gpu_name or 'CPU'}" f"{_startup_line}", elem_classes=["info-card"], ) model_select.change( on_model_change, inputs=[model_select, backend_select], outputs=[beam_size, model_badge], ) backend_select.change( on_backend_change, inputs=[backend_select, model_select], outputs=[engine_hint, model_badge], ) auto_batch.change(on_auto_batch_toggle, inputs=[auto_batch], outputs=[manual_batch]) gpu_install_btn.click( lambda: gr.update(visible=True), outputs=[gpu_install_log], ).then( install_gpu_torch_ui, outputs=[gpu_install_btn, gpu_install_log], ) # ── Nhập liệu (văn bản / file) ──────────────────────────────── with gr.Tabs(): with gr.Tab("📝 Dịch văn bản"): source = gr.Textbox( label="Văn bản gốc (Tiếng Trung)", placeholder="粘贴中文原文…", lines=10, elem_classes=["source-input"], ) text_btn = gr.Button("Dịch văn bản", variant="primary", size="lg") with gr.Tab("📄 Dịch file .txt"): gr.Markdown("Upload file `.txt` tiếng Trung → dịch toàn bộ → tải file `.txt` tiếng Việt.") file_input = gr.File(label="Chọn file .txt đầu vào", file_types=[".txt"], type="filepath") file_preview = gr.Textbox( label="Xem trước nội dung file", lines=8, interactive=False, elem_classes=["file-preview"], ) file_btn = gr.Button("Dịch file & xuất .txt", variant="primary", size="lg") def preview_file(file_obj) -> str: if not file_obj: return "" return read_text_file(Path(file_obj), max_chars=8000) file_input.change(preview_file, inputs=[file_input], outputs=[file_preview]) # ── Tiến trình (một thanh duy nhất) ─────────────────────────── progress_html = gr.HTML(render_progress_html(0, "Sẵn sàng.", False)) # ── Kết quả ─────────────────────────────────────────────────── result_summary = gr.Markdown(elem_id="result-summary") with gr.Accordion("Đối chiếu song song (bản gốc · bản dịch)", open=True): compare_view = gr.HTML(render_compare_html([]), elem_id="compare-view") with gr.Accordion("Bản dịch đầy đủ & xuất file", open=True): full_output = gr.Textbox( label="Bản dịch đầy đủ", lines=10, interactive=False, elem_id="full-output", ) # Giữ bản dịch ĐẦY ĐỦ (không bị cap hiển thị) để nút Xuất .txt dùng. full_text_state = gr.State("") with gr.Row(): export_btn = gr.Button("💾 Xuất bản dịch .txt", variant="secondary") download_file = gr.File(label="Tải file bản dịch (.txt)") result_outputs = [ compare_view, full_output, status, download_file, result_summary, full_text_state, ] translate_inputs = [ model_select, backend_select, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, ] text_btn.click( translate_text_ui, inputs=[source, *translate_inputs], outputs=result_outputs, concurrency_limit=1, concurrency_id="translate", ) file_btn.click( translate_file_ui, inputs=[file_input, *translate_inputs], outputs=result_outputs, concurrency_limit=1, concurrency_id="translate", ) export_btn.click( lambda text: export_translation(text, "hachimimt"), inputs=[full_text_state], outputs=[download_file], ) progress_timer = gr.Timer(0.3, active=True) progress_timer.tick( poll_progress_ui, outputs=[progress_html], show_progress=False, ) return demo def main() -> None: if not IS_HF_SPACE: # Space: process do HF quản, không cần PID write_pid_file() atexit.register(remove_pid_file) else: # Trên Space: cache + warmup model mặc định (HachimiMT-60 CT2) lúc KHỞI ĐỘNG # (startup runtime, trong main() — KHÔNG phải build-time) → user mở lên dịch # được NGAY, không chờ tải lần đầu. Hiệu quả vì Space giữ process chạy lâu. # Lỗi (mạng) KHÔNG chặn app — fallback lazy-download khi dịch. try: print(f"[Space] cpu_count={os.cpu_count()} threads={os.environ.get('HACHIMIMT_THREADS')} " f"batch={os.environ.get('HACHIMIMT_BATCH_SIZE')} — cache + warmup {DEFAULT_MODEL_KEY}...", flush=True) ensure_model_files(MODELS[DEFAULT_MODEL_KEY], Backend.CT2) # load() đã tự warmup (dịch 1 câu) → user dịch là nhanh ngay. translator.load(DEFAULT_MODEL_KEY, Backend.CT2) print(f"[Space] Đã cache + warmup {DEFAULT_MODEL_KEY}.", flush=True) except Exception as exc: # noqa: BLE001 — preload best-effort, không fatal print(f"[Space] Preload/warmup lỗi (sẽ lazy khi dịch): {exc}", flush=True) reset_progress() demo = build_ui() demo.queue(default_concurrency_limit=8) # Gradio 6: theme/css/head truyền ở launch() (không còn ở Blocks()). favicon = Path(__file__).resolve().parent / "assets" / "favicon.svg" launch_kwargs = dict( theme=gr.themes.Soft(primary_hue="orange", neutral_hue="stone"), css=CUSTOM_CSS, head=HEAD_HTML, favicon_path=str(favicon) if favicon.exists() else None, allowed_paths=[str(EXPORTS_DIR), tempfile.gettempdir()], ) # HACHIMIMT_SHARE=1 (Google Colab / máy từ xa): tạo link share công khai # (gradio.live), bind 0.0.0.0 để truy cập từ ngoài. share = os.environ.get("HACHIMIMT_SHARE", "").strip() == "1" if share: launch_kwargs["share"] = True launch_kwargs["server_name"] = "0.0.0.0" elif not IS_HF_SPACE: # local: bind localhost cố định; Space: HF tự lo launch_kwargs["server_name"] = "127.0.0.1" launch_kwargs["server_port"] = APP_PORT demo.launch(**launch_kwargs) if __name__ == "__main__": main()