Spaces:
Running
Running
| """ | |
| Gradio demo — Document Processing (Smart Doc + Translation). | |
| Tabs: | |
| - Smart Doc → https://connect.zhihuiya.com/rd-llm/v1/documents/doc_parsing | |
| - Translation → https://connect.zhihuiya.com/hiro_translation | |
| Auth: env HIRO_API_KEY, fallback RD_LLM_API_KEY (shared for both tabs). | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| import os | |
| import time | |
| from pathlib import Path | |
| # Shared hosts often leave /tmp/gradio owned by another user → PermissionError on upload. | |
| _GRADIO_TMP = Path(__file__).resolve().parent / ".gradio_tmp" | |
| _GRADIO_TMP.mkdir(parents=True, exist_ok=True) | |
| os.environ.setdefault("GRADIO_TEMP_DIR", str(_GRADIO_TMP)) | |
| import gradio as gr | |
| from api_client import ( | |
| DEFAULT_BASE, | |
| SMARTDOC_URL, | |
| parse_document, | |
| resolve_api_key, | |
| iter_translate_fast, | |
| stream_translate, | |
| ) | |
| CUSTOM_CSS = """ | |
| .gradio-container { | |
| max-width: 100% !important; | |
| } | |
| .main-title { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| margin: 0 0 0.25rem 0; | |
| color: var(--block-title-text-color, var(--color-accent, var(--body-text-color))); | |
| } | |
| .status-pill { | |
| display: inline-block; | |
| padding: 0.4rem 0.75rem; | |
| border-radius: var(--block-radius); | |
| font-size: 0.8rem; | |
| line-height: 1.4; | |
| border: 1px solid var(--block-border-color); | |
| background: var(--block-background-fill); | |
| color: var(--body-text-color); | |
| } | |
| .status-pill.ok { | |
| border-color: var(--border-color-accent-subdued, var(--block-border-color)); | |
| color: var(--color-accent, var(--body-text-color)); | |
| } | |
| .status-pill.err { | |
| border-color: var(--error-border-color, var(--block-border-color)); | |
| color: var(--error-text-color, var(--body-text-color)); | |
| } | |
| .col-header { | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--body-text-color-subdued); | |
| padding: 0.5rem 0.75rem; | |
| border-bottom: 1px solid var(--block-border-color); | |
| background: var(--block-background-fill); | |
| } | |
| .pairs-wrap { | |
| max-height: 62vh; | |
| overflow-y: auto; | |
| overflow-anchor: auto; | |
| border: 1px solid var(--block-border-color); | |
| border-radius: var(--block-radius); | |
| background: var(--block-background-fill); | |
| } | |
| .pair-block { | |
| border-bottom: 1px solid var(--block-border-color); | |
| } | |
| .pair-block:last-child { border-bottom: none; } | |
| .pair-idx { | |
| padding: 0.35rem 1rem; | |
| font-size: 0.7rem; | |
| color: var(--body-text-color-subdued); | |
| background: var(--body-background-fill); | |
| border-bottom: 1px solid var(--block-border-color); | |
| } | |
| .pair-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0; | |
| } | |
| .pair-cell { | |
| padding: 0.85rem 1rem; | |
| font-family: var(--font-mono); | |
| font-size: 0.82rem; | |
| line-height: 1.55; | |
| color: var(--body-text-color); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .pair-cell.src { | |
| border-right: 1px solid var(--block-border-color); | |
| background: var(--input-background-fill); | |
| } | |
| .pair-cell.tgt { | |
| background: var(--background-fill-secondary, var(--block-background-fill)); | |
| } | |
| .header-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| } | |
| .empty-hint { | |
| padding: 3rem 1rem; | |
| text-align: center; | |
| color: var(--body-text-color-subdued); | |
| font-size: 0.9rem; | |
| } | |
| .results-stack { | |
| display: flex; | |
| flex-direction: column; | |
| gap: var(--spacing-md); | |
| } | |
| .results-panel-col { | |
| display: flex; | |
| flex-direction: column; | |
| gap: var(--spacing-md); | |
| } | |
| .progress-section { | |
| flex: 0 0 auto; | |
| padding: 0.6rem 0.85rem; | |
| border: 1px solid var(--block-border-color); | |
| border-radius: var(--block-radius); | |
| background: var(--block-background-fill); | |
| } | |
| .progress-meta { | |
| font-size: 0.8rem; | |
| color: var(--body-text-color-subdued); | |
| margin-bottom: 0.45rem; | |
| line-height: 1.4; | |
| } | |
| .progress-track { | |
| height: 6px; | |
| border-radius: 999px; | |
| background: var(--background-fill-secondary, var(--input-background-fill)); | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| border-radius: 999px; | |
| background: var(--button-primary-background-fill); | |
| } | |
| .progress-pct { | |
| margin-top: 0.35rem; | |
| font-size: 0.72rem; | |
| color: var(--body-text-color-subdued); | |
| text-align: right; | |
| } | |
| #input-box textarea { | |
| font-size: 0.95rem !important; | |
| line-height: 1.6 !important; | |
| border-radius: var(--block-radius) !important; | |
| } | |
| .toolbar-row > :last-child { | |
| margin-left: auto; | |
| } | |
| .hiro-full-translation { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: pre-wrap; | |
| border: 0; | |
| resize: none; | |
| } | |
| footer { display: none !important; } | |
| .smartdoc-status { | |
| font-size: 0.9rem; | |
| color: var(--body-text-color-subdued); | |
| margin: 0.25rem 0 0.75rem 0; | |
| } | |
| .smartdoc-status.ok { color: var(--color-accent, #047857); } | |
| .smartdoc-status.err { color: var(--error-text-color, #b42318); } | |
| """ | |
| RESULTS_SHELL_HTML = """ | |
| <div class="results-stack"> | |
| <textarea class="hiro-full-translation" readonly aria-hidden="true" tabindex="-1"></textarea> | |
| <div class="progress-section"> | |
| <div class="progress-meta" id="hiro-progress-meta">Ready</div> | |
| <div class="progress-track"> | |
| <div class="progress-fill" id="hiro-progress-fill" style="width:0%"></div> | |
| </div> | |
| <div class="progress-pct" id="hiro-progress-pct">0%</div> | |
| </div> | |
| <div class="pairs-wrap" id="hiro-pairs-scroll"> | |
| <div id="hiro-pairs-header"></div> | |
| <div id="hiro-pairs-body"> | |
| <div class="empty-hint">Translation results will appear here</div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| LANG_OPTIONS = [ | |
| ("Chinese", "zh"), | |
| ("English", "en"), | |
| ("Japanese", "ja"), | |
| ("Korean", "ko"), | |
| ("German", "de"), | |
| ("French", "fr"), | |
| ("Russian", "ru"), | |
| ("Spanish", "es"), | |
| ("Portuguese", "pt"), | |
| ("Italian", "it"), | |
| ("Dutch", "nl"), | |
| ("Arabic", "ar"), | |
| ("Hindi", "hi"), | |
| ("Thai", "th"), | |
| ("Vietnamese", "vi"), | |
| ("Indonesian", "id"), | |
| ("Turkish", "tr"), | |
| ("Polish", "pl"), | |
| ] | |
| EXAMPLES = [ | |
| ["本发明公开了一种档案管理文件储存用分类标识装置。", "zh", "en"], | |
| [ | |
| "本发明公开了一种档案管理文件储存用分类标识装置。\n我是智慧芽的研发", | |
| "zh", | |
| "en", | |
| ], | |
| [ | |
| "The present invention relates to a heat exchanger comprising a plurality of fluid passages.", | |
| "en", | |
| "zh", | |
| ], | |
| [ | |
| "1.一种蓝莓早促快发春梢的绿色生产技术,其特征在于,包括:(1)新主枝的培养、(2)调节树体结构。", | |
| "zh", | |
| "en", | |
| ], | |
| ] | |
| _STREAM_YIELD_INTERVAL_S = 0.12 | |
| _STREAM_YIELD_MIN_NEW_SEGMENTS = 8 | |
| def _resolve_base(api_base: str) -> str: | |
| return (api_base or DEFAULT_BASE).strip().rstrip("/") | |
| def _parse_progress(progress: str | None) -> tuple[int, int]: | |
| if not progress or "/" not in progress: | |
| return 0, 0 | |
| try: | |
| cur, total = progress.split("/", 1) | |
| return int(cur.strip()), int(total.strip()) | |
| except ValueError: | |
| return 0, 0 | |
| def _full_translation_text(pairs: list[tuple[str, str]]) -> str: | |
| return "".join(trans for _, trans in pairs if trans) | |
| def _pairs_for_display(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]: | |
| return [(orig, trans) for orig, trans in pairs if orig.strip() or trans.strip()] | |
| def _progress_percent(progress: str | None, pair_count: int) -> float: | |
| cur, total = _parse_progress(progress) | |
| if total > 0: | |
| return min(100.0, round(100.0 * cur / total, 1)) | |
| if pair_count > 0: | |
| return 100.0 | |
| return 0.0 | |
| def _stream_state_payload( | |
| *, | |
| visible_pairs: list[tuple[str, str]], | |
| full: str, | |
| lang: str, | |
| percent: float, | |
| status: str, | |
| reset: bool = False, | |
| ) -> str: | |
| return json.dumps( | |
| { | |
| "reset": reset, | |
| "visible_pairs": visible_pairs, | |
| "total_visible_count": len(visible_pairs), | |
| "full": full, | |
| "percent": max(0.0, min(100.0, float(percent))), | |
| "status": status, | |
| "lang": lang, | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| def _pack_outputs(*, stream_payload: str, panel_visible: bool, copy_enabled: bool) -> tuple: | |
| return ( | |
| stream_payload, | |
| gr.update(visible=panel_visible), | |
| gr.update(interactive=copy_enabled), | |
| ) | |
| def _empty_outputs(): | |
| payload = _stream_state_payload( | |
| visible_pairs=[], | |
| full="", | |
| lang="zh2en", | |
| percent=0, | |
| status="Waiting for input…", | |
| reset=True, | |
| ) | |
| return _pack_outputs(stream_payload=payload, panel_visible=False, copy_enabled=False) | |
| def _translation_outputs( | |
| pairs: list[tuple[str, str]], | |
| *, | |
| lang: str, | |
| percent: float, | |
| status: str, | |
| panel_visible: bool = True, | |
| reset: bool = False, | |
| ): | |
| visible = _pairs_for_display(pairs) | |
| full = _full_translation_text(pairs) | |
| payload = _stream_state_payload( | |
| visible_pairs=visible, | |
| full=full, | |
| lang=lang, | |
| percent=percent, | |
| status=status, | |
| reset=reset, | |
| ) | |
| return _pack_outputs( | |
| stream_payload=payload, | |
| panel_visible=panel_visible, | |
| copy_enabled=bool(full.strip()), | |
| ) | |
| COPY_TRANSLATION_JS = """ | |
| () => { | |
| const ta = document.querySelector(".hiro-full-translation"); | |
| const text = ta ? ta.value : ""; | |
| if (!String(text).trim()) { | |
| alert("Nothing to copy yet"); | |
| return []; | |
| } | |
| const copyFallback = (value) => { | |
| const el = document.createElement("textarea"); | |
| el.value = value; | |
| el.style.position = "fixed"; | |
| el.style.left = "-9999px"; | |
| document.body.appendChild(el); | |
| el.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(el); | |
| }; | |
| const done = () => { | |
| const btn = document.getElementById("copy-translation-btn"); | |
| if (!btn) return; | |
| const label = btn.querySelector("button") || btn; | |
| const orig = label.textContent; | |
| label.textContent = "Copied"; | |
| setTimeout(() => { label.textContent = orig; }, 1500); | |
| }; | |
| if (navigator.clipboard && window.isSecureContext) { | |
| navigator.clipboard.writeText(String(text)).then(done).catch(() => { | |
| copyFallback(String(text)); | |
| done(); | |
| }); | |
| } else { | |
| copyFallback(String(text)); | |
| done(); | |
| } | |
| return []; | |
| } | |
| """ | |
| UPDATE_RESULTS_JS = """ | |
| (stateJson) => { | |
| if (window.__hiroApplyStreamState == null) { | |
| window.__hiroApplyStreamState = (raw) => { | |
| if (window.__hiroResults == null) { | |
| window.__hiroResults = { count: 0, layout: null, lastJson: "" }; | |
| } | |
| let state; | |
| try { | |
| state = JSON.parse(raw || "{}"); | |
| } catch (_err) { | |
| return; | |
| } | |
| const store = window.__hiroResults; | |
| if (state.reset) { | |
| store.count = 0; | |
| store.layout = null; | |
| store.lastJson = ""; | |
| } | |
| if (raw === store.lastJson) return; | |
| store.lastJson = raw; | |
| const esc = (value) => { | |
| const el = document.createElement("div"); | |
| el.textContent = value == null ? "" : String(value); | |
| return el.innerHTML; | |
| }; | |
| const labelsFor = (lang) => { | |
| const codeMap = { | |
| zh: "ZH", en: "EN", ja: "JA", ko: "KO", de: "DE", | |
| fr: "FR", ru: "RU", es: "ES", pt: "PT", it: "IT", | |
| nl: "NL", ar: "AR", hi: "HI", th: "TH", vi: "VI", | |
| id: "ID", tr: "TR", pl: "PL", | |
| }; | |
| const parts = String(lang || "zh2en").split("2"); | |
| const srcCode = (parts[0] || "zh").toLowerCase(); | |
| const tgtCode = (parts[1] || "en").toLowerCase(); | |
| const src = codeMap[srcCode] || srcCode.toUpperCase(); | |
| const tgt = codeMap[tgtCode] || tgtCode.toUpperCase(); | |
| return { src: `Source (${src})`, tgt: `Target (${tgt})` }; | |
| }; | |
| const meta = document.getElementById("hiro-progress-meta"); | |
| const fill = document.getElementById("hiro-progress-fill"); | |
| const pct = document.getElementById("hiro-progress-pct"); | |
| const header = document.getElementById("hiro-pairs-header"); | |
| const body = document.getElementById("hiro-pairs-body"); | |
| const scroll = document.getElementById("hiro-pairs-scroll"); | |
| const ta = document.querySelector(".hiro-full-translation"); | |
| const pairs = Array.isArray(state.visible_pairs) ? state.visible_pairs : []; | |
| const layoutKey = state.lang || "zh2en"; | |
| const totalVisible = state.total_visible_count != null | |
| ? state.total_visible_count | |
| : pairs.length; | |
| if (meta) meta.textContent = state.status || ""; | |
| if (fill) fill.style.width = `${state.percent || 0}%`; | |
| if (pct) pct.textContent = `${Math.round(state.percent || 0)}%`; | |
| if (ta) ta.value = state.full || ""; | |
| document.querySelectorAll(".hiro-full-translation").forEach((el) => { | |
| el.value = state.full || ""; | |
| }); | |
| if (!header || !body || !scroll) return; | |
| if (state.reset || store.layout !== layoutKey) { | |
| store.count = 0; | |
| store.layout = layoutKey; | |
| body.innerHTML = ""; | |
| const labels = labelsFor(state.lang); | |
| header.innerHTML = ` | |
| <div class="header-row"> | |
| <div class="col-header">${esc(labels.src)}</div> | |
| <div class="col-header">${esc(labels.tgt)}</div> | |
| </div>`; | |
| } | |
| if (totalVisible < store.count) { | |
| store.count = 0; | |
| body.innerHTML = ""; | |
| } | |
| if (totalVisible === 0 && store.count === 0) { | |
| if (!body.querySelector(".empty-hint")) { | |
| body.innerHTML = '<div class="empty-hint">Translation results will appear here</div>'; | |
| } | |
| } else { | |
| const hint = body.querySelector(".empty-hint"); | |
| if (hint) hint.remove(); | |
| } | |
| const savedScroll = scroll.scrollTop; | |
| const startIdx = store.count; | |
| const newPairs = pairs.slice(startIdx); | |
| for (let i = 0; i < newPairs.length; i++) { | |
| const [orig, trans] = newPairs[i]; | |
| const displayIdx = startIdx + i + 1; | |
| body.insertAdjacentHTML( | |
| "beforeend", | |
| `<div class="pair-block" data-idx="${displayIdx}"> | |
| <div class="pair-idx">Segment ${displayIdx}</div> | |
| <div class="pair-row"> | |
| <div class="pair-cell src">${esc(orig)}</div> | |
| <div class="pair-cell tgt">${esc(trans)}</div> | |
| </div> | |
| </div>` | |
| ); | |
| } | |
| store.count = totalVisible; | |
| scroll.scrollTop = savedScroll; | |
| }; | |
| } | |
| window.__hiroApplyStreamState(stateJson); | |
| return []; | |
| } | |
| """ | |
| INIT_STREAM_WATCH_JS = """ | |
| () => { | |
| if (window.__hiroStreamWatch) return []; | |
| window.__hiroStreamWatch = true; | |
| const bind = () => { | |
| const input = document.querySelector("#hiro-stream-state textarea"); | |
| if (!input) return false; | |
| let last = input.value; | |
| setInterval(() => { | |
| if (input.value !== last) { | |
| last = input.value; | |
| if (typeof window.__hiroApplyStreamState === "function") { | |
| window.__hiroApplyStreamState(input.value); | |
| } | |
| } | |
| }, 120); | |
| return true; | |
| }; | |
| if (!bind()) { | |
| const boot = new MutationObserver(() => { | |
| if (bind()) boot.disconnect(); | |
| }); | |
| boot.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| return []; | |
| } | |
| """ | |
| def run_translation(text: str, lang: str, mode: str): | |
| text = (text or "").strip() | |
| if not text: | |
| yield _empty_outputs() | |
| return | |
| base = _resolve_base(DEFAULT_BASE) | |
| auth = resolve_api_key() or None | |
| pairs: list[tuple[str, str]] = [] | |
| t0 = time.perf_counter() | |
| last_progress = "0/0" | |
| last_emitted_visible = 0 | |
| last_yield_t = 0.0 | |
| def emit(*, percent: float, status: str, panel_visible: bool = True, reset: bool = False, force: bool = False): | |
| nonlocal last_emitted_visible, last_yield_t | |
| visible = _pairs_for_display(pairs) | |
| if not force and not reset and len(visible) == last_emitted_visible and percent < 100: | |
| return None | |
| last_emitted_visible = len(visible) | |
| last_yield_t = time.perf_counter() | |
| return _translation_outputs( | |
| pairs, | |
| lang=lang, | |
| percent=percent, | |
| status=status, | |
| panel_visible=panel_visible, | |
| reset=reset, | |
| ) | |
| yield emit(percent=0, status="Connecting to gateway…", reset=True) | |
| try: | |
| if mode == "fast": | |
| # Async fast: POST /translate/async then poll GET /translate/async/{taskId} | |
| task_id = "" | |
| for event in iter_translate_fast(text, lang, base_url=base, api_key=auth): | |
| if not event.get("done"): | |
| task_id = str(event.get("task_id") or task_id) | |
| short = f"{task_id[:8]}…" if task_id else "…" | |
| state = event.get("state") or "pending" | |
| phase = event.get("phase") or "polling" | |
| elapsed = time.perf_counter() - t0 | |
| if phase == "submitted": | |
| status = f"Submitted async task {short} · {elapsed:.1f}s" | |
| pct = 5.0 | |
| else: | |
| status = f"Polling {short} · {state} · {elapsed:.1f}s" | |
| pct = min(90.0, 10.0 + elapsed) | |
| out = emit(percent=pct, status=status, force=True) | |
| if out is not None: | |
| yield out | |
| continue | |
| orig = event.get("text_original", text) | |
| trans = event.get("text_translated", "") | |
| elapsed = time.perf_counter() - t0 | |
| pairs = [(orig, trans)] | |
| status = f"Done · fast (async) · {elapsed:.1f}s" | |
| out_chars = event.get("translated_character_count") | |
| if out_chars is not None: | |
| status += f" · {out_chars} chars" | |
| if task_id or event.get("task_id"): | |
| tid = str(event.get("task_id") or task_id) | |
| status += f" · task {tid[:8]}…" | |
| yield emit(percent=100, status=status, force=True) | |
| return | |
| for chunk in stream_translate(text, lang, base_url=base, api_key=auth): | |
| orig = chunk.get("text_original") or "" | |
| trans = chunk.get("text_translated") or "" | |
| if orig or trans: | |
| pairs.append((orig, trans)) | |
| last_progress = chunk.get("progress") or last_progress | |
| pct = _progress_percent(last_progress, len(pairs)) | |
| total = _parse_progress(last_progress)[1] | |
| desc = ( | |
| f"Translating {last_progress}" | |
| if total | |
| else f"Received {len(pairs)} segment(s)" | |
| ) | |
| elapsed = time.perf_counter() - t0 | |
| visible = _pairs_for_display(pairs) | |
| new_visible = len(visible) - last_emitted_visible | |
| now = time.perf_counter() | |
| if ( | |
| now - last_yield_t >= _STREAM_YIELD_INTERVAL_S | |
| or new_visible >= _STREAM_YIELD_MIN_NEW_SEGMENTS | |
| ): | |
| out = emit( | |
| percent=pct, | |
| status=f"Streaming · {desc} · {elapsed:.1f}s", | |
| ) | |
| if out is not None: | |
| yield out | |
| elapsed = time.perf_counter() - t0 | |
| yield emit( | |
| percent=100, | |
| status=( | |
| f"Done · {len(pairs)} segment(s) · {elapsed:.1f}s · {last_progress}" | |
| ), | |
| force=True, | |
| ) | |
| except Exception as exc: | |
| elapsed = time.perf_counter() - t0 | |
| err = html.escape(str(exc)) | |
| payload = _stream_state_payload( | |
| visible_pairs=[], | |
| full="", | |
| lang=lang, | |
| percent=0, | |
| status=f"Failed · {elapsed:.1f}s · {err}", | |
| reset=True, | |
| ) | |
| yield _pack_outputs(stream_payload=payload, panel_visible=True, copy_enabled=False) | |
| def _file_path(file_obj) -> str | None: | |
| if file_obj is None: | |
| return None | |
| if isinstance(file_obj, (list, tuple)): | |
| return _file_path(file_obj[0]) if file_obj else None | |
| if isinstance(file_obj, str): | |
| return file_obj | |
| return getattr(file_obj, "name", None) or getattr(file_obj, "path", None) | |
| def run_smartdoc(file_obj): | |
| path = _file_path(file_obj) | |
| if not path: | |
| return ( | |
| '<p class="smartdoc-status err">Please select a file first</p>', | |
| "*Rendered Markdown will appear here after parsing.*", | |
| "[]", | |
| ) | |
| try: | |
| data = parse_document(path) | |
| pages = data.get("total_pages") or 0 | |
| results = data.get("results") or [] | |
| markdown = data.get("markdown") or "" | |
| status = ( | |
| f'<p class="smartdoc-status ok">Complete · {pages} pages · ' | |
| f"{len(results)} results</p>" | |
| ) | |
| if not markdown.strip(): | |
| markdown = "*No Markdown content was returned.*" | |
| return status, markdown, json.dumps(results, ensure_ascii=False, indent=2) | |
| except Exception as exc: | |
| return ( | |
| f'<p class="smartdoc-status err">Parsing failed: {html.escape(str(exc))}</p>', | |
| "*Parsing failed. Check the status message.*", | |
| "[]", | |
| ) | |
| def build_theme() -> gr.Theme: | |
| return gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="indigo", | |
| neutral_hue="slate", | |
| radius_size="lg", | |
| font=[gr.themes.GoogleFont("DM Sans"), "system-ui", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], | |
| ) | |
| def build_ui() -> gr.Blocks: | |
| with gr.Blocks( | |
| title="Document Processing Demo", | |
| css=CUSTOM_CSS, | |
| theme=build_theme(), | |
| ) as demo: | |
| gr.HTML('<p class="main-title">Document Processing Demo</p>') | |
| with gr.Tabs(): | |
| with gr.Tab("Smart Doc"): | |
| gr.Markdown( | |
| f"Upload a document for layout + OCR parsing via " | |
| f"`{SMARTDOC_URL}` · PDF / image / Office · **10 MB** max" | |
| ) | |
| doc_file = gr.File( | |
| label="Document", | |
| file_count="single", | |
| file_types=[ | |
| ".pdf", | |
| ".png", | |
| ".jpg", | |
| ".jpeg", | |
| ".doc", | |
| ".docx", | |
| ".ppt", | |
| ".pptx", | |
| ".xls", | |
| ".xlsx", | |
| ".odt", | |
| ".odp", | |
| ".ods", | |
| ".rtf", | |
| ], | |
| ) | |
| with gr.Row(elem_classes=["toolbar-row"]): | |
| parse_btn = gr.Button("Parse Document", variant="primary", scale=0) | |
| clear_doc_btn = gr.Button("Clear", scale=0) | |
| smartdoc_status = gr.HTML( | |
| value='<p class="smartdoc-status">Select a file to begin</p>' | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Markdown"): | |
| smartdoc_md = gr.Markdown( | |
| value="*Rendered Markdown will appear here after parsing.*", | |
| latex_delimiters=[ | |
| {"left": "$$", "right": "$$", "display": True}, | |
| {"left": "$", "right": "$", "display": False}, | |
| {"left": "\\(", "right": "\\)", "display": False}, | |
| {"left": "\\[", "right": "\\]", "display": True}, | |
| ], | |
| ) | |
| with gr.Tab("Results"): | |
| smartdoc_json = gr.Code( | |
| value="[]", | |
| language="json", | |
| label="Structured results", | |
| lines=22, | |
| ) | |
| with gr.Tab("Translation"): | |
| with gr.Row(): | |
| source_lang = gr.Dropdown( | |
| choices=LANG_OPTIONS, | |
| value="zh", | |
| label="Source language", | |
| scale=1, | |
| ) | |
| target_lang = gr.Dropdown( | |
| choices=LANG_OPTIONS, | |
| value="en", | |
| label="Target language", | |
| scale=1, | |
| ) | |
| mode = gr.Radio( | |
| choices=["stream", "fast"], | |
| value="stream", | |
| label="Mode", | |
| info=( | |
| "stream: SSE segments via POST /translate; " | |
| "fast: async submit + poll via /translate/async" | |
| ), | |
| scale=1, | |
| ) | |
| lang = gr.Textbox(value="zh2en", visible=False) | |
| gr.Markdown("**Examples**") | |
| example_btns: list[tuple[gr.Button, str, str, str]] = [] | |
| with gr.Row(): | |
| for i, (sample, src, tgt) in enumerate(EXAMPLES, start=1): | |
| preview = sample.replace("\n", " ")[:28] | |
| if len(sample) > 28: | |
| preview += "…" | |
| btn = gr.Button(f"{i}. {preview}", size="sm", variant="secondary") | |
| example_btns.append((btn, sample, src, tgt)) | |
| input_text = gr.Textbox( | |
| label="Source text", | |
| placeholder=( | |
| "Enter patent-related text. Supports Chinese, English, " | |
| "Japanese, Korean, and more." | |
| ), | |
| lines=8, | |
| elem_id="input-box", | |
| ) | |
| with gr.Row(elem_classes=["toolbar-row"]): | |
| submit_btn = gr.Button("Translate", variant="primary", scale=0) | |
| clear_btn = gr.Button("Clear", scale=0) | |
| copy_btn = gr.Button( | |
| "Copy full translation", | |
| variant="secondary", | |
| interactive=False, | |
| scale=0, | |
| elem_id="copy-translation-btn", | |
| ) | |
| with gr.Column( | |
| visible=False, elem_classes=["results-panel-col"] | |
| ) as results_panel: | |
| gr.HTML(RESULTS_SHELL_HTML, elem_id="translation-results") | |
| stream_state = gr.Textbox( | |
| value=_stream_state_payload( | |
| visible_pairs=[], | |
| full="", | |
| lang="zh2en", | |
| percent=0, | |
| status="Ready", | |
| ), | |
| visible=False, | |
| elem_id="hiro-stream-state", | |
| ) | |
| def _combine_lang(src: str, tgt: str) -> str: | |
| return f"{src}2{tgt}" | |
| source_lang.change( | |
| _combine_lang, inputs=[source_lang, target_lang], outputs=[lang] | |
| ) | |
| target_lang.change( | |
| _combine_lang, inputs=[source_lang, target_lang], outputs=[lang] | |
| ) | |
| for btn, sample, src, tgt in example_btns: | |
| btn.click( | |
| lambda _s=sample, _src=src, _tgt=tgt: (_s, _src, _tgt, f"{_src}2{_tgt}"), | |
| outputs=[input_text, source_lang, target_lang, lang], | |
| ) | |
| submit_btn.click( | |
| fn=run_translation, | |
| inputs=[input_text, lang, mode], | |
| outputs=[stream_state, results_panel, copy_btn], | |
| ) | |
| stream_state.change( | |
| fn=None, | |
| inputs=[stream_state], | |
| outputs=None, | |
| js=UPDATE_RESULTS_JS, | |
| queue=False, | |
| ) | |
| copy_btn.click( | |
| fn=None, | |
| inputs=None, | |
| outputs=None, | |
| js=COPY_TRANSLATION_JS, | |
| queue=False, | |
| ) | |
| def on_clear(): | |
| payload = _stream_state_payload( | |
| visible_pairs=[], | |
| full="", | |
| lang="zh2en", | |
| percent=0, | |
| status="Waiting for input…", | |
| reset=True, | |
| ) | |
| return "", *_pack_outputs( | |
| stream_payload=payload, panel_visible=False, copy_enabled=False | |
| ) | |
| clear_btn.click( | |
| on_clear, | |
| outputs=[input_text, stream_state, results_panel, copy_btn], | |
| ) | |
| parse_btn.click( | |
| fn=run_smartdoc, | |
| inputs=[doc_file], | |
| outputs=[smartdoc_status, smartdoc_md, smartdoc_json], | |
| ) | |
| def on_clear_doc(): | |
| return ( | |
| None, | |
| '<p class="smartdoc-status">Select a file to begin</p>', | |
| "*Rendered Markdown will appear here after parsing.*", | |
| "[]", | |
| ) | |
| clear_doc_btn.click( | |
| on_clear_doc, | |
| outputs=[doc_file, smartdoc_status, smartdoc_md, smartdoc_json], | |
| ) | |
| demo.load(fn=None, js=INIT_STREAM_WATCH_JS) | |
| return demo | |
| demo = build_ui() | |
| demo.queue(default_concurrency_limit=2) | |
| if __name__ == "__main__": | |
| # Spaces health-check needs 0.0.0.0 (not 127.0.0.1). | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |