Spaces:
Running
Running
| """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=<github blob url> → 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 ( | |
| '<div class="gpu-hint">' | |
| f"⚡ Phát hiện <b>{gpu}</b> nhưng app đang chạy bằng <b>CPU</b> " | |
| "(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)." | |
| "</div>" | |
| ) | |
| # 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 <head> để giảm nhấp nháy khi tải. | |
| HEAD_HTML = """ | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| """ | |
| 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 <body> 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: <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'<a class="model-hf-link" href="{url}" target="_blank" rel="noopener">' | |
| "↗ Trang Hugging Face</a>" | |
| ) | |
| 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 = '<span class="model-badge ready">✓ Đã tải — dịch được ngay</span>' | |
| return f'<div class="model-meta">{badge}{link}</div>' | |
| 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'<span class="model-badge pending">⬇ Chưa có{size} — ' | |
| "sẽ tự tải từ Hugging Face ở lần dịch đầu</span>" | |
| ) | |
| return f'<div class="model-meta">{badge}{link}</div>' | |
| 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""" | |
| <div class="progress-wrap"> | |
| <div class="progress-head"> | |
| <span class="progress-pct">{pct:.0f}%</span> | |
| <span class="progress-state">{safe_status}</span> | |
| </div> | |
| <div class="progress-track"> | |
| <div class="progress-fill{running_cls}" style="width: {pct:.1f}%;"></div> | |
| </div> | |
| <div class="progress-msg">{safe_message}</div> | |
| </div> | |
| """ | |
| def poll_progress_ui() -> str: | |
| state = snapshot() | |
| return render_progress_html(state.pct, state.message, state.running) | |
| EMPTY_COMPARE_HTML = ( | |
| '<div class="compare-empty">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.</div>" | |
| ) | |
| 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 = [ | |
| '<div class="cmp-head">' | |
| '<span class="cmp-num"></span>' | |
| '<div class="cmp-col">Tiếng Trung</div>' | |
| '<div class="cmp-col">Tiếng Việt</div>' | |
| "</div>" | |
| ] | |
| for idx, zh, vi in display: | |
| safe_zh = html.escape(zh, quote=True) | |
| safe_vi = html.escape(vi, quote=True) | |
| items.append( | |
| f'<div class="cmp-row">' | |
| f'<span class="cmp-num">{idx}</span>' | |
| f'<p class="cmp-zh">{safe_zh}</p>' | |
| f'<p class="cmp-vi">{safe_vi}</p>' | |
| f"</div>" | |
| ) | |
| note = "" | |
| if len(rows) > MAX_TABLE_ROWS: | |
| note = ( | |
| f'<div class="cmp-note">Hiển thị {MAX_TABLE_ROWS}/{len(rows)} đoạn đầu. ' | |
| "Xem bản dịch đầy đủ ở khối bên dưới.</div>" | |
| ) | |
| return f'<div class="compare-list">{"".join(items)}{note}</div>' | |
| 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('<div id="app-rule"></div>') | |
| # ── Banner demo (chỉ trên Space) ────────────────────────────── | |
| if IS_HF_SPACE: | |
| gr.HTML( | |
| '<div id="demo-banner">' | |
| 'Đây là <b>bản demo chạy CPU</b> — phù hợp dịch thử đoạn ngắn. ' | |
| 'Cần <b>GPU</b>, dịch <b>file/chương dài</b> hay chạy <b>offline</b>? ' | |
| f'<a href="{COLAB_URL}" target="_blank" rel="noopener">☁️ chạy trên Google Colab</a> ' | |
| f'/ <a href="{KAGGLE_NOTEBOOK_URL}" target="_blank" rel="noopener">Kaggle T4×2</a> ' | |
| f'hoặc <a href="{LOCAL_ZIP_URL}">⬇ tải bản cài máy</a> ' | |
| '(chi tiết ở mục “Dùng nhanh/mạnh hơn” cuối trang).' | |
| "</div>" | |
| ) | |
| # ── 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( | |
| "<span class=\"exp-badge\">🧪 Thử nghiệm</span> 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ì) | |
| []({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) | |
| []({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() | |