Spaces:
Running
Running
Jiawei Dong commited on
Commit ·
4a9a269
1
Parent(s): 8074673
Add hiro-translation-api Gradio demo
Browse files- README.md +26 -1
- __pycache__/api_client.cpython-38.pyc +0 -0
- __pycache__/app.cpython-38.pyc +0 -0
- api_client.py +92 -0
- app.py +832 -0
- requirements.txt +1 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|