""" 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 = """
Ready
0%
Translation results will appear here
""" 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 = `
${esc(labels.src)}
${esc(labels.tgt)}
`; } if (totalVisible < store.count) { store.count = 0; body.innerHTML = ""; } if (totalVisible === 0 && store.count === 0) { if (!body.querySelector(".empty-hint")) { body.innerHTML = '
Translation results will appear here
'; } } 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", `
Segment ${displayIdx}
${esc(orig)}
${esc(trans)}
` ); } 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 ( '

Please select a file first

', "*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'

Complete · {pages} pages · ' f"{len(results)} results

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

Parsing failed: {html.escape(str(exc))}

', "*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('

Document Processing Demo

') 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='

Select a file to begin

' ) 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, '

Select a file to begin

', "*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)