Jiawei Dong commited on
Commit
4a9a269
·
1 Parent(s): 8074673

Add hiro-translation-api Gradio demo

Browse files
README.md CHANGED
@@ -9,6 +9,31 @@ python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
+ short_description: Patent translation demo via hiro-translation-api
13
  ---
14
 
15
+ # HIRO Translation
16
+
17
+ Patent translation demo powered by **hiro-translation-api** (stage).
18
+
19
+ - **stream** — SSE streaming, segment-by-segment display
20
+ - **fast** — single JSON response with full translation
21
+ - Default backend: `stage-s-patsnaprd-hiro-translation-api-internet.patsnap.info`
22
+
23
+ ## Files
24
+
25
+ | File | Purpose |
26
+ |------|---------|
27
+ | `app.py` | Gradio UI |
28
+ | `api_client.py` | HTTP client for `/compute/hiro_translation_api` |
29
+ | `requirements.txt` | Python dependencies (`requests`; Gradio is pre-installed on Spaces) |
30
+
31
+ ## Local run
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ python app.py
36
+ ```
37
+
38
+ Check out the [Spaces config reference](https://huggingface.co/docs/hub/spaces-config-reference).
39
+
__pycache__/api_client.cpython-38.pyc ADDED
Binary file (2.72 kB). View file
 
__pycache__/app.cpython-38.pyc ADDED
Binary file (21.9 kB). View file
 
api_client.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP client for hiro-translation-api (stage gateway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ DEFAULT_BASE = (
12
+ "http://stage-s-patsnaprd-hiro-translation-api-internet.patsnap.info"
13
+ )
14
+ API_PREFIX = "/compute/hiro_translation_api"
15
+
16
+
17
+ def _api_url(base_url: str, path: str) -> str:
18
+ return f"{base_url.rstrip('/')}{API_PREFIX}{path}"
19
+
20
+
21
+ def health_ok(base_url: str = DEFAULT_BASE) -> tuple[bool, str]:
22
+ try:
23
+ r = requests.get(_api_url(base_url, "/health"), timeout=12)
24
+ r.raise_for_status()
25
+ data = r.json()
26
+ if data.get("status") != "OK":
27
+ return False, f"异常响应: {r.text[:200]}"
28
+ upstream = data.get("upstream", "UNKNOWN")
29
+ if upstream == "OK":
30
+ return True, "服务正常 · 上游 OK"
31
+ return False, f"网关在线,上游不可用 ({upstream})"
32
+ except requests.RequestException as exc:
33
+ return False, str(exc)
34
+
35
+
36
+ def translate_fast(
37
+ text: str,
38
+ lang: str,
39
+ *,
40
+ base_url: str = DEFAULT_BASE,
41
+ timeout: int = 1800,
42
+ ) -> dict[str, Any]:
43
+ r = requests.post(
44
+ _api_url(base_url, "/translate"),
45
+ json={"text": text, "lang": lang, "mode": "fast"},
46
+ timeout=timeout,
47
+ )
48
+ if r.status_code >= 400:
49
+ try:
50
+ err = r.json().get("error", r.text[:500])
51
+ except json.JSONDecodeError:
52
+ err = r.text[:500]
53
+ raise RuntimeError(err)
54
+ return r.json()
55
+
56
+
57
+ def _iter_sse_json(resp: requests.Response) -> Iterator[dict[str, Any]]:
58
+ for raw in resp.iter_lines(decode_unicode=True):
59
+ if not raw:
60
+ continue
61
+ line = raw.strip()
62
+ if not line.startswith("data:"):
63
+ continue
64
+ payload = line[5:].lstrip()
65
+ if not payload:
66
+ continue
67
+ chunk = json.loads(payload)
68
+ if isinstance(chunk, dict) and chunk.get("error"):
69
+ raise RuntimeError(str(chunk["error"]))
70
+ yield chunk
71
+
72
+
73
+ def stream_translate(
74
+ text: str,
75
+ lang: str,
76
+ *,
77
+ base_url: str = DEFAULT_BASE,
78
+ timeout: int = 1800,
79
+ ) -> Iterator[dict[str, Any]]:
80
+ with requests.post(
81
+ _api_url(base_url, "/translate"),
82
+ json={"text": text, "lang": lang, "mode": "stream"},
83
+ stream=True,
84
+ timeout=timeout,
85
+ ) as resp:
86
+ if resp.status_code >= 400:
87
+ try:
88
+ err = resp.json().get("error", resp.text[:500])
89
+ except (json.JSONDecodeError, ValueError):
90
+ err = resp.text[:500]
91
+ raise RuntimeError(err)
92
+ yield from _iter_sse_json(resp)
app.py ADDED
@@ -0,0 +1,832 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio demo — HIRO Translation API (hiro-translation-api).
3
+
4
+ Calls stage gateway by default:
5
+ http://stage-s-patsnaprd-hiro-translation-api-internet.patsnap.info
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import json
12
+ import time
13
+
14
+ import gradio as gr
15
+
16
+ from api_client import DEFAULT_BASE, health_ok, stream_translate, translate_fast
17
+
18
+ CUSTOM_CSS = """
19
+ .gradio-container {
20
+ max-width: 100% !important;
21
+ }
22
+
23
+ .main-title {
24
+ font-size: 1.5rem;
25
+ font-weight: 700;
26
+ margin: 0 0 0.25rem 0;
27
+ color: var(--block-title-text-color, var(--color-accent, var(--body-text-color)));
28
+ }
29
+
30
+ .status-pill {
31
+ display: inline-block;
32
+ padding: 0.4rem 0.75rem;
33
+ border-radius: var(--block-radius);
34
+ font-size: 0.8rem;
35
+ line-height: 1.4;
36
+ border: 1px solid var(--block-border-color);
37
+ background: var(--block-background-fill);
38
+ color: var(--body-text-color);
39
+ }
40
+ .status-pill.ok {
41
+ border-color: var(--border-color-accent-subdued, var(--block-border-color));
42
+ color: var(--color-accent, var(--body-text-color));
43
+ }
44
+ .status-pill.err {
45
+ border-color: var(--error-border-color, var(--block-border-color));
46
+ color: var(--error-text-color, var(--body-text-color));
47
+ }
48
+
49
+ .col-header {
50
+ font-size: 0.72rem;
51
+ font-weight: 600;
52
+ letter-spacing: 0.08em;
53
+ text-transform: uppercase;
54
+ color: var(--body-text-color-subdued);
55
+ padding: 0.5rem 0.75rem;
56
+ border-bottom: 1px solid var(--block-border-color);
57
+ background: var(--block-background-fill);
58
+ }
59
+
60
+ .pairs-wrap {
61
+ max-height: 62vh;
62
+ overflow-y: auto;
63
+ overflow-anchor: auto;
64
+ border: 1px solid var(--block-border-color);
65
+ border-radius: var(--block-radius);
66
+ background: var(--block-background-fill);
67
+ }
68
+
69
+ .pair-block {
70
+ border-bottom: 1px solid var(--block-border-color);
71
+ }
72
+ .pair-block:last-child { border-bottom: none; }
73
+
74
+ .pair-idx {
75
+ padding: 0.35rem 1rem;
76
+ font-size: 0.7rem;
77
+ color: var(--body-text-color-subdued);
78
+ background: var(--body-background-fill);
79
+ border-bottom: 1px solid var(--block-border-color);
80
+ }
81
+
82
+ .pair-row {
83
+ display: grid;
84
+ grid-template-columns: 1fr 1fr;
85
+ gap: 0;
86
+ }
87
+
88
+ .pair-cell {
89
+ padding: 0.85rem 1rem;
90
+ font-family: var(--font-mono);
91
+ font-size: 0.82rem;
92
+ line-height: 1.55;
93
+ color: var(--body-text-color);
94
+ white-space: pre-wrap;
95
+ word-break: break-word;
96
+ }
97
+ .pair-cell.src {
98
+ border-right: 1px solid var(--block-border-color);
99
+ background: var(--input-background-fill);
100
+ }
101
+ .pair-cell.tgt {
102
+ background: var(--background-fill-secondary, var(--block-background-fill));
103
+ }
104
+
105
+ .header-row {
106
+ display: grid;
107
+ grid-template-columns: 1fr 1fr;
108
+ }
109
+
110
+ .empty-hint {
111
+ padding: 3rem 1rem;
112
+ text-align: center;
113
+ color: var(--body-text-color-subdued);
114
+ font-size: 0.9rem;
115
+ }
116
+
117
+ .results-stack {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: var(--spacing-md);
121
+ }
122
+
123
+ .results-panel-col {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: var(--spacing-md);
127
+ }
128
+
129
+ .progress-section {
130
+ flex: 0 0 auto;
131
+ padding: 0.6rem 0.85rem;
132
+ border: 1px solid var(--block-border-color);
133
+ border-radius: var(--block-radius);
134
+ background: var(--block-background-fill);
135
+ }
136
+
137
+ .progress-meta {
138
+ font-size: 0.8rem;
139
+ color: var(--body-text-color-subdued);
140
+ margin-bottom: 0.45rem;
141
+ line-height: 1.4;
142
+ }
143
+
144
+ .progress-track {
145
+ height: 6px;
146
+ border-radius: 999px;
147
+ background: var(--background-fill-secondary, var(--input-background-fill));
148
+ overflow: hidden;
149
+ }
150
+
151
+ .progress-fill {
152
+ height: 100%;
153
+ border-radius: 999px;
154
+ background: var(--button-primary-background-fill);
155
+ }
156
+
157
+ .progress-pct {
158
+ margin-top: 0.35rem;
159
+ font-size: 0.72rem;
160
+ color: var(--body-text-color-subdued);
161
+ text-align: right;
162
+ }
163
+
164
+ #input-box textarea {
165
+ font-size: 0.95rem !important;
166
+ line-height: 1.6 !important;
167
+ border-radius: var(--block-radius) !important;
168
+ }
169
+
170
+ .toolbar-row > :last-child {
171
+ margin-left: auto;
172
+ }
173
+
174
+ .hiro-full-translation {
175
+ position: absolute;
176
+ width: 1px;
177
+ height: 1px;
178
+ padding: 0;
179
+ margin: -1px;
180
+ overflow: hidden;
181
+ clip: rect(0, 0, 0, 0);
182
+ white-space: pre-wrap;
183
+ border: 0;
184
+ resize: none;
185
+ }
186
+
187
+ footer { display: none !important; }
188
+ """
189
+
190
+ RESULTS_SHELL_HTML = """
191
+ <div class="results-stack">
192
+ <textarea class="hiro-full-translation" readonly aria-hidden="true" tabindex="-1"></textarea>
193
+ <div class="progress-section">
194
+ <div class="progress-meta" id="hiro-progress-meta">就绪</div>
195
+ <div class="progress-track">
196
+ <div class="progress-fill" id="hiro-progress-fill" style="width:0%"></div>
197
+ </div>
198
+ <div class="progress-pct" id="hiro-progress-pct">0%</div>
199
+ </div>
200
+ <div class="pairs-wrap" id="hiro-pairs-scroll">
201
+ <div id="hiro-pairs-header"></div>
202
+ <div id="hiro-pairs-body">
203
+ <div class="empty-hint">翻译结果将显示在此处</div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ """
208
+
209
+ LANG_OPTIONS = [
210
+ ("中文", "zh"),
211
+ ("英语", "en"),
212
+ ("日语", "ja"),
213
+ ("韩语", "ko"),
214
+ ("德语", "de"),
215
+ ("法语", "fr"),
216
+ ("俄语", "ru"),
217
+ ("西班牙语", "es"),
218
+ ("葡萄牙语", "pt"),
219
+ ("意大利语", "it"),
220
+ ("荷兰语", "nl"),
221
+ ("阿拉伯语", "ar"),
222
+ ("印地语", "hi"),
223
+ ("泰语", "th"),
224
+ ("越南语", "vi"),
225
+ ("印尼语", "id"),
226
+ ("土耳其语", "tr"),
227
+ ("波兰语", "pl"),
228
+ ]
229
+
230
+ EXAMPLES = [
231
+ ["一种低损耗换热器", "zh", "en"],
232
+ [
233
+ "The present invention relates to a heat exchanger comprising a plurality of fluid passages.",
234
+ "en",
235
+ "zh",
236
+ ],
237
+ [
238
+ "1.一种蓝莓早促快发春梢的绿色生产技术,其特征在于,包括:(1)新主枝的培养、(2)调节树体结构。",
239
+ "zh",
240
+ "en",
241
+ ],
242
+ [
243
+ "## 技术领域\n\n本发明涉及一种换热器。",
244
+ "zh",
245
+ "en",
246
+ ],
247
+ ]
248
+
249
+ _STREAM_YIELD_INTERVAL_S = 0.12
250
+ _STREAM_YIELD_MIN_NEW_SEGMENTS = 8
251
+
252
+
253
+ def _resolve_base(api_base: str) -> str:
254
+ return (api_base or DEFAULT_BASE).strip().rstrip("/")
255
+
256
+
257
+ def check_service(api_base: str) -> str:
258
+ ok, msg = health_ok(_resolve_base(api_base))
259
+ base = _resolve_base(api_base)
260
+ if ok:
261
+ return (
262
+ f'<span class="status-pill ok">● 在线<br>'
263
+ f'<span style="opacity:0.85">{html.escape(base)}</span></span>'
264
+ )
265
+ return (
266
+ f'<span class="status-pill err">● 不可用<br>'
267
+ f'<span style="opacity:0.85">{html.escape(msg)}</span></span>'
268
+ )
269
+
270
+
271
+ def _parse_progress(progress: str | None) -> tuple[int, int]:
272
+ if not progress or "/" not in progress:
273
+ return 0, 0
274
+ try:
275
+ cur, total = progress.split("/", 1)
276
+ return int(cur.strip()), int(total.strip())
277
+ except ValueError:
278
+ return 0, 0
279
+
280
+
281
+ def _full_translation_text(pairs: list[tuple[str, str]]) -> str:
282
+ return "".join(trans for _, trans in pairs if trans)
283
+
284
+
285
+ def _pairs_for_display(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
286
+ return [(orig, trans) for orig, trans in pairs if orig.strip() or trans.strip()]
287
+
288
+
289
+ def _progress_percent(progress: str | None, pair_count: int) -> float:
290
+ cur, total = _parse_progress(progress)
291
+ if total > 0:
292
+ return min(100.0, round(100.0 * cur / total, 1))
293
+ if pair_count > 0:
294
+ return 100.0
295
+ return 0.0
296
+
297
+
298
+ def _stream_state_payload(
299
+ *,
300
+ visible_pairs: list[tuple[str, str]],
301
+ full: str,
302
+ lang: str,
303
+ percent: float,
304
+ status: str,
305
+ reset: bool = False,
306
+ ) -> str:
307
+ return json.dumps(
308
+ {
309
+ "reset": reset,
310
+ "visible_pairs": visible_pairs,
311
+ "total_visible_count": len(visible_pairs),
312
+ "full": full,
313
+ "percent": max(0.0, min(100.0, float(percent))),
314
+ "status": status,
315
+ "lang": lang,
316
+ },
317
+ ensure_ascii=False,
318
+ )
319
+
320
+
321
+ def _pack_outputs(*, stream_payload: str, panel_visible: bool, copy_enabled: bool) -> tuple:
322
+ return (
323
+ stream_payload,
324
+ gr.update(visible=panel_visible),
325
+ gr.update(interactive=copy_enabled),
326
+ )
327
+
328
+
329
+ def _empty_outputs():
330
+ payload = _stream_state_payload(
331
+ visible_pairs=[],
332
+ full="",
333
+ lang="zh2en",
334
+ percent=0,
335
+ status="等待输入…",
336
+ reset=True,
337
+ )
338
+ return _pack_outputs(stream_payload=payload, panel_visible=False, copy_enabled=False)
339
+
340
+
341
+ def _translation_outputs(
342
+ pairs: list[tuple[str, str]],
343
+ *,
344
+ lang: str,
345
+ percent: float,
346
+ status: str,
347
+ panel_visible: bool = True,
348
+ reset: bool = False,
349
+ ):
350
+ visible = _pairs_for_display(pairs)
351
+ full = _full_translation_text(pairs)
352
+ payload = _stream_state_payload(
353
+ visible_pairs=visible,
354
+ full=full,
355
+ lang=lang,
356
+ percent=percent,
357
+ status=status,
358
+ reset=reset,
359
+ )
360
+ return _pack_outputs(
361
+ stream_payload=payload,
362
+ panel_visible=panel_visible,
363
+ copy_enabled=bool(full.strip()),
364
+ )
365
+
366
+
367
+ COPY_TRANSLATION_JS = """
368
+ () => {
369
+ const ta = document.querySelector(".hiro-full-translation");
370
+ const text = ta ? ta.value : "";
371
+ if (!String(text).trim()) {
372
+ alert("暂无译文可复制");
373
+ return [];
374
+ }
375
+ const copyFallback = (value) => {
376
+ const el = document.createElement("textarea");
377
+ el.value = value;
378
+ el.style.position = "fixed";
379
+ el.style.left = "-9999px";
380
+ document.body.appendChild(el);
381
+ el.select();
382
+ document.execCommand("copy");
383
+ document.body.removeChild(el);
384
+ };
385
+ const done = () => {
386
+ const btn = document.getElementById("copy-translation-btn");
387
+ if (!btn) return;
388
+ const label = btn.querySelector("button") || btn;
389
+ const orig = label.textContent;
390
+ label.textContent = "已复制";
391
+ setTimeout(() => { label.textContent = orig; }, 1500);
392
+ };
393
+ if (navigator.clipboard && window.isSecureContext) {
394
+ navigator.clipboard.writeText(String(text)).then(done).catch(() => {
395
+ copyFallback(String(text));
396
+ done();
397
+ });
398
+ } else {
399
+ copyFallback(String(text));
400
+ done();
401
+ }
402
+ return [];
403
+ }
404
+ """
405
+
406
+ UPDATE_RESULTS_JS = """
407
+ (stateJson) => {
408
+ if (window.__hiroApplyStreamState == null) {
409
+ window.__hiroApplyStreamState = (raw) => {
410
+ if (window.__hiroResults == null) {
411
+ window.__hiroResults = { count: 0, layout: null, lastJson: "" };
412
+ }
413
+ if (raw === window.__hiroResults.lastJson) return;
414
+ window.__hiroResults.lastJson = raw;
415
+
416
+ const store = window.__hiroResults;
417
+ const esc = (value) => {
418
+ const el = document.createElement("div");
419
+ el.textContent = value == null ? "" : String(value);
420
+ return el.innerHTML;
421
+ };
422
+ const labelsFor = (lang) => {
423
+ const codeMap = {
424
+ zh: "ZH", en: "EN", ja: "JA", ko: "KO", de: "DE",
425
+ fr: "FR", ru: "RU", es: "ES", pt: "PT", it: "IT",
426
+ nl: "NL", ar: "AR", hi: "HI", th: "TH", vi: "VI",
427
+ id: "ID", tr: "TR", pl: "PL",
428
+ };
429
+ const parts = String(lang || "zh2en").split("2");
430
+ const srcCode = (parts[0] || "zh").toLowerCase();
431
+ const tgtCode = (parts[1] || "en").toLowerCase();
432
+ const src = codeMap[srcCode] || srcCode.toUpperCase();
433
+ const tgt = codeMap[tgtCode] || tgtCode.toUpperCase();
434
+ return { src: `原文 (${src})`, tgt: `译文 (${tgt})` };
435
+ };
436
+
437
+ let state;
438
+ try {
439
+ state = JSON.parse(raw || "{}");
440
+ } catch (_err) {
441
+ return;
442
+ }
443
+
444
+ const meta = document.getElementById("hiro-progress-meta");
445
+ const fill = document.getElementById("hiro-progress-fill");
446
+ const pct = document.getElementById("hiro-progress-pct");
447
+ const header = document.getElementById("hiro-pairs-header");
448
+ const body = document.getElementById("hiro-pairs-body");
449
+ const scroll = document.getElementById("hiro-pairs-scroll");
450
+ const ta = document.querySelector(".hiro-full-translation");
451
+ const pairs = Array.isArray(state.visible_pairs) ? state.visible_pairs : [];
452
+ const layoutKey = state.lang || "zh2en";
453
+ const totalVisible = state.total_visible_count != null
454
+ ? state.total_visible_count
455
+ : pairs.length;
456
+
457
+ if (meta) meta.textContent = state.status || "";
458
+ if (fill) fill.style.width = `${state.percent || 0}%`;
459
+ if (pct) pct.textContent = `${Math.round(state.percent || 0)}%`;
460
+ if (ta) ta.value = state.full || "";
461
+ document.querySelectorAll(".hiro-full-translation").forEach((el) => {
462
+ el.value = state.full || "";
463
+ });
464
+ if (!header || !body || !scroll) return;
465
+
466
+ if (state.reset || store.layout !== layoutKey) {
467
+ store.count = 0;
468
+ store.layout = layoutKey;
469
+ body.innerHTML = "";
470
+ const labels = labelsFor(state.lang);
471
+ header.innerHTML = `
472
+ <div class="header-row">
473
+ <div class="col-header">${esc(labels.src)}</div>
474
+ <div class="col-header">${esc(labels.tgt)}</div>
475
+ </div>`;
476
+ }
477
+
478
+ if (totalVisible < store.count) {
479
+ store.count = 0;
480
+ body.innerHTML = "";
481
+ }
482
+
483
+ if (totalVisible === 0 && store.count === 0) {
484
+ if (!body.querySelector(".empty-hint")) {
485
+ body.innerHTML = '<div class="empty-hint">翻译结果将显示在此处</div>';
486
+ }
487
+ } else {
488
+ const hint = body.querySelector(".empty-hint");
489
+ if (hint) hint.remove();
490
+ }
491
+
492
+ const savedScroll = scroll.scrollTop;
493
+ const startIdx = store.count;
494
+ const newPairs = pairs.slice(startIdx);
495
+
496
+ for (let i = 0; i < newPairs.length; i++) {
497
+ const [orig, trans] = newPairs[i];
498
+ const displayIdx = startIdx + i + 1;
499
+ body.insertAdjacentHTML(
500
+ "beforeend",
501
+ `<div class="pair-block" data-idx="${displayIdx}">
502
+ <div class="pair-idx">第 ${displayIdx} 段</div>
503
+ <div class="pair-row">
504
+ <div class="pair-cell src">${esc(orig)}</div>
505
+ <div class="pair-cell tgt">${esc(trans)}</div>
506
+ </div>
507
+ </div>`
508
+ );
509
+ }
510
+
511
+ store.count = totalVisible;
512
+ scroll.scrollTop = savedScroll;
513
+ };
514
+ }
515
+
516
+ window.__hiroApplyStreamState(stateJson);
517
+ return [];
518
+ }
519
+ """
520
+
521
+ INIT_STREAM_WATCH_JS = """
522
+ (api_base) => {
523
+ if (window.__hiroStreamWatch) return [api_base];
524
+ window.__hiroStreamWatch = true;
525
+
526
+ const bind = () => {
527
+ const input = document.querySelector("#hiro-stream-state textarea");
528
+ if (!input) return false;
529
+ let last = input.value;
530
+ setInterval(() => {
531
+ if (input.value !== last) {
532
+ last = input.value;
533
+ if (typeof window.__hiroApplyStreamState === "function") {
534
+ window.__hiroApplyStreamState(input.value);
535
+ }
536
+ }
537
+ }, 120);
538
+ return true;
539
+ };
540
+
541
+ if (!bind()) {
542
+ const boot = new MutationObserver(() => {
543
+ if (bind()) boot.disconnect();
544
+ });
545
+ boot.observe(document.body, { childList: true, subtree: true });
546
+ }
547
+ return [api_base];
548
+ }
549
+ """
550
+
551
+ RESET_STREAM_JS = """
552
+ (text, lang, mode, api_base) => {
553
+ if (window.__hiroResults) {
554
+ window.__hiroResults.count = 0;
555
+ window.__hiroResults.layout = null;
556
+ window.__hiroResults.lastJson = "";
557
+ }
558
+ return [text, lang, mode, api_base];
559
+ }
560
+ """
561
+
562
+ CLEAR_STREAM_JS = """
563
+ () => {
564
+ if (window.__hiroResults) {
565
+ window.__hiroResults.count = 0;
566
+ window.__hiroResults.layout = null;
567
+ window.__hiroResults.lastJson = "";
568
+ }
569
+ return [];
570
+ }
571
+ """
572
+
573
+
574
+ def run_translation(text: str, lang: str, mode: str, api_base: str):
575
+ text = (text or "").strip()
576
+ if not text:
577
+ yield _empty_outputs()
578
+ return
579
+
580
+ base = _resolve_base(api_base)
581
+ pairs: list[tuple[str, str]] = []
582
+ t0 = time.perf_counter()
583
+ last_progress = "0/0"
584
+ last_emitted_visible = 0
585
+ last_yield_t = 0.0
586
+
587
+ def emit(*, percent: float, status: str, panel_visible: bool = True, reset: bool = False, force: bool = False):
588
+ nonlocal last_emitted_visible, last_yield_t
589
+ visible = _pairs_for_display(pairs)
590
+ if not force and not reset and len(visible) == last_emitted_visible and percent < 100:
591
+ return None
592
+ last_emitted_visible = len(visible)
593
+ last_yield_t = time.perf_counter()
594
+ return _translation_outputs(
595
+ pairs,
596
+ lang=lang,
597
+ percent=percent,
598
+ status=status,
599
+ panel_visible=panel_visible,
600
+ reset=reset,
601
+ )
602
+
603
+ yield emit(percent=0, status="正在连接网关…", reset=True)
604
+
605
+ try:
606
+ if mode == "fast":
607
+ data = translate_fast(text, lang, base_url=base)
608
+ orig = data.get("text_original", text)
609
+ trans = data.get("text_translated", "")
610
+ engine = data.get("engine", "")
611
+ elapsed = time.perf_counter() - t0
612
+ pairs = [(orig, trans)]
613
+ status = f"完成 · fast 模式 · {elapsed:.1f}s"
614
+ if engine:
615
+ status += f" · {engine}"
616
+ yield emit(percent=100, status=status, force=True)
617
+ return
618
+
619
+ for chunk in stream_translate(text, lang, base_url=base):
620
+ orig = chunk.get("text_original") or ""
621
+ trans = chunk.get("text_translated") or ""
622
+ if orig or trans:
623
+ pairs.append((orig, trans))
624
+
625
+ last_progress = chunk.get("progress") or last_progress
626
+ pct = _progress_percent(last_progress, len(pairs))
627
+ total = _parse_progress(last_progress)[1]
628
+ desc = f"翻译中 {last_progress}" if total else f"已收到 {len(pairs)} 段"
629
+ elapsed = time.perf_counter() - t0
630
+
631
+ visible = _pairs_for_display(pairs)
632
+ new_visible = len(visible) - last_emitted_visible
633
+ now = time.perf_counter()
634
+ if (
635
+ now - last_yield_t >= _STREAM_YIELD_INTERVAL_S
636
+ or new_visible >= _STREAM_YIELD_MIN_NEW_SEGMENTS
637
+ ):
638
+ out = emit(
639
+ percent=pct,
640
+ status=f"流式翻译中 · {desc} · {elapsed:.1f}s",
641
+ )
642
+ if out is not None:
643
+ yield out
644
+
645
+ elapsed = time.perf_counter() - t0
646
+ yield emit(
647
+ percent=100,
648
+ status=f"完成 · 共 {len(pairs)} 段 · {elapsed:.1f}s · {last_progress}",
649
+ force=True,
650
+ )
651
+
652
+ except Exception as exc:
653
+ elapsed = time.perf_counter() - t0
654
+ err = html.escape(str(exc))
655
+ payload = _stream_state_payload(
656
+ visible_pairs=[],
657
+ full="",
658
+ lang=lang,
659
+ percent=0,
660
+ status=f"失败 · {elapsed:.1f}s · {err}",
661
+ reset=True,
662
+ )
663
+ yield _pack_outputs(stream_payload=payload, panel_visible=True, copy_enabled=False)
664
+
665
+
666
+ def build_theme() -> gr.Theme:
667
+ return gr.themes.Soft(
668
+ primary_hue="blue",
669
+ secondary_hue="indigo",
670
+ neutral_hue="slate",
671
+ radius_size="lg",
672
+ font=[gr.themes.GoogleFont("DM Sans"), "system-ui", "sans-serif"],
673
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
674
+ )
675
+
676
+
677
+ def build_ui() -> gr.Blocks:
678
+ with gr.Blocks(
679
+ title="HIRO Translation",
680
+ css=CUSTOM_CSS,
681
+ theme=build_theme(),
682
+ ) as demo:
683
+ with gr.Sidebar():
684
+ gr.HTML('<p class="main-title">HIRO Translation</p>')
685
+ gr.Markdown("专利翻译 · `hiro-translation-api`")
686
+
687
+ gateway_status = gr.HTML(value=check_service(DEFAULT_BASE))
688
+ refresh_btn = gr.Button("检查网关", size="sm", variant="secondary")
689
+
690
+ with gr.Row():
691
+ source_lang = gr.Dropdown(
692
+ choices=LANG_OPTIONS,
693
+ value="zh",
694
+ label="源语言",
695
+ )
696
+ target_lang = gr.Dropdown(
697
+ choices=LANG_OPTIONS,
698
+ value="en",
699
+ label="目标语言",
700
+ )
701
+ lang = gr.Textbox(value="zh2en", visible=False)
702
+ mode = gr.Radio(
703
+ choices=["stream", "fast"],
704
+ value="stream",
705
+ label="调用模式",
706
+ info="stream:流式分段;fast:一次返回完整译文",
707
+ )
708
+
709
+ with gr.Accordion("高级选项", open=False):
710
+ api_base = gr.Textbox(
711
+ label="API Base URL",
712
+ value=DEFAULT_BASE,
713
+ placeholder=DEFAULT_BASE,
714
+ )
715
+
716
+ gr.Markdown("**快速示例**")
717
+ example_btns: list[tuple[gr.Button, str, str, str]] = []
718
+ for i, (sample, src, tgt) in enumerate(EXAMPLES, start=1):
719
+ preview = sample.replace("\n", " ")[:36]
720
+ if len(sample) > 36:
721
+ preview += "…"
722
+ btn = gr.Button(f"{i}. {preview}", size="sm", variant="secondary")
723
+ example_btns.append((btn, sample, src, tgt))
724
+
725
+ with gr.Column():
726
+ input_text = gr.Textbox(
727
+ label="待翻译文本",
728
+ placeholder="输入专利相关文本,支持中英日韩及多语种互译",
729
+ lines=8,
730
+ elem_id="input-box",
731
+ )
732
+ with gr.Row(elem_classes=["toolbar-row"]):
733
+ submit_btn = gr.Button("开始翻译", variant="primary", scale=0)
734
+ clear_btn = gr.Button("清空", scale=0)
735
+ copy_btn = gr.Button(
736
+ "复制完整译文",
737
+ variant="secondary",
738
+ interactive=False,
739
+ scale=0,
740
+ elem_id="copy-translation-btn",
741
+ )
742
+
743
+ with gr.Column(visible=False, elem_classes=["results-panel-col"]) as results_panel:
744
+ gr.HTML(RESULTS_SHELL_HTML, elem_id="translation-results")
745
+ stream_state = gr.Textbox(
746
+ value=_stream_state_payload(
747
+ visible_pairs=[],
748
+ full="",
749
+ lang="zh2en",
750
+ percent=0,
751
+ status="就绪",
752
+ ),
753
+ visible=False,
754
+ elem_id="hiro-stream-state",
755
+ )
756
+
757
+ refresh_btn.click(check_service, inputs=[api_base], outputs=[gateway_status])
758
+ api_base.change(check_service, inputs=[api_base], outputs=[gateway_status])
759
+
760
+ def _combine_lang(src: str, tgt: str) -> str:
761
+ return f"{src}2{tgt}"
762
+
763
+ source_lang.change(
764
+ _combine_lang, inputs=[source_lang, target_lang], outputs=[lang]
765
+ )
766
+ target_lang.change(
767
+ _combine_lang, inputs=[source_lang, target_lang], outputs=[lang]
768
+ )
769
+
770
+ for btn, sample, src, tgt in example_btns:
771
+ btn.click(
772
+ lambda _s=sample, _src=src, _tgt=tgt: (_s, _src, _tgt, f"{_src}2{_tgt}"),
773
+ outputs=[input_text, source_lang, target_lang, lang],
774
+ )
775
+
776
+ submit_btn.click(
777
+ fn=run_translation,
778
+ inputs=[input_text, lang, mode, api_base],
779
+ outputs=[stream_state, results_panel, copy_btn],
780
+ js=RESET_STREAM_JS,
781
+ )
782
+
783
+ stream_state.change(
784
+ fn=None,
785
+ inputs=[stream_state],
786
+ outputs=None,
787
+ js=UPDATE_RESULTS_JS,
788
+ queue=False,
789
+ )
790
+
791
+ copy_btn.click(
792
+ fn=None,
793
+ inputs=None,
794
+ outputs=None,
795
+ js=COPY_TRANSLATION_JS,
796
+ queue=False,
797
+ )
798
+
799
+ def on_clear():
800
+ payload = _stream_state_payload(
801
+ visible_pairs=[],
802
+ full="",
803
+ lang="zh2en",
804
+ percent=0,
805
+ status="等待输入…",
806
+ reset=True,
807
+ )
808
+ return "", *_pack_outputs(
809
+ stream_payload=payload, panel_visible=False, copy_enabled=False
810
+ )
811
+
812
+ clear_btn.click(
813
+ on_clear,
814
+ outputs=[input_text, stream_state, results_panel, copy_btn],
815
+ js=CLEAR_STREAM_JS,
816
+ )
817
+
818
+ demo.load(
819
+ check_service,
820
+ inputs=[api_base],
821
+ outputs=[gateway_status],
822
+ js=INIT_STREAM_WATCH_JS,
823
+ )
824
+
825
+ return demo
826
+
827
+
828
+ demo = build_ui()
829
+
830
+ if __name__ == "__main__":
831
+ demo.queue(default_concurrency_limit=2)
832
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ requests>=2.28.0