From 2585d13b04c83e85db3fd7d1c72498fe3d99412a Mon Sep 17 00:00:00 2001 From: hikariming Date: Wed, 17 Jun 2026 09:03:23 +0800 Subject: [PATCH 1/2] Stream PDF translation progress to UI and console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF translation ran via a blocking Command::output(), so the app showed no feedback for the whole (often minutes-long) run and looked frozen on Windows. - Python sidecar: emit a per-page `OPENKOTO_PROGRESS {json}` line on stdout (current/total/percent) via the existing translate_patch callback, plus a readable log line on stderr. - Rust translate_pdf_document: spawn the sidecar with piped stdio and stream it live — parse progress markers into a `pdf-translation-progress` Tauri event, print all sidecar output to the console as it arrives, and settle at 100% on success. Replaces the blocking .output() call. - Frontend BookReader: listen for progress events and show "翻译中 N%" on the translate button; reset on completion. Mock the event API in tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../openkoto_pdf_translator/pdf2zh.py | 37 +++++- textlingo-desktop/src-tauri/src/commands.rs | 105 +++++++++++++----- .../components/features/BookReader.test.tsx | 4 + .../src/components/features/BookReader.tsx | 23 +++- 4 files changed, 140 insertions(+), 29 deletions(-) diff --git a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py index 01e015c..dd9c4ab 100644 --- a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py +++ b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse +import json import logging import sys from string import Template @@ -24,6 +25,38 @@ logger = logging.getLogger(__name__) +# Marker prefix the desktop app (Rust side) parses out of stdout to drive the +# progress UI. Keep it in sync with `translate_pdf_document` in commands.rs. +OPENKOTO_PROGRESS_PREFIX = "OPENKOTO_PROGRESS " + + +def _emit_openkoto_progress(progress) -> None: + """Emit a machine-readable per-page progress line plus a human log line. + + `progress` is the tqdm object passed by high_level.translate_patch, so + `progress.n` is the number of pages processed and `progress.total` the page + count. Stdout carries the structured marker; stderr carries a readable log. + """ + try: + current = int(getattr(progress, "n", 0) or 0) + total = int(getattr(progress, "total", 0) or 0) + percent = int(current * 100 / total) if total else 0 + payload = { + "type": "progress", + "current": current, + "total": total, + "percent": percent, + } + print(OPENKOTO_PROGRESS_PREFIX + json.dumps(payload), flush=True) + print( + f"[PDF] translating page {current}/{total} ({percent}%)", + file=sys.stderr, + flush=True, + ) + except Exception: + # Progress reporting must never break a translation. + pass + def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__, add_help=True) @@ -322,10 +355,10 @@ def main(args: Optional[List[str]] = None) -> int: if parsed_args.dir: untranlate_file = find_all_files_in_directory(parsed_args.files[0]) parsed_args.files = untranlate_file - translate(model=ModelInstance.value, **vars(parsed_args)) + translate(model=ModelInstance.value, callback=_emit_openkoto_progress, **vars(parsed_args)) return 0 - translate(model=ModelInstance.value, **vars(parsed_args)) + translate(model=ModelInstance.value, callback=_emit_openkoto_progress, **vars(parsed_args)) return 0 diff --git a/textlingo-desktop/src-tauri/src/commands.rs b/textlingo-desktop/src-tauri/src/commands.rs index 27f5a85..2574dd4 100644 --- a/textlingo-desktop/src-tauri/src/commands.rs +++ b/textlingo-desktop/src-tauri/src/commands.rs @@ -2979,7 +2979,8 @@ pub async fn translate_pdf_document( base_url: Option, ) -> Result { use crate::pdf_sidecar; - use std::process::Command; + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; println!( "[PDF Translate] Starting translation: {} -> {}", @@ -3049,36 +3050,88 @@ pub async fn translate_pdf_document( command .args(&args) .envs(envs.iter().map(|(k, v)| (*k, v.as_str()))) - .current_dir(&plugin_dir); // 关键:设置工作目录为插件目录 + .current_dir(&plugin_dir) // 关键:设置工作目录为插件目录 + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); pdf_sidecar::hide_console_window(&mut command); // Windows: 避免弹出黑色 cmd 窗口 - let result = command.output(); - match result { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - println!("[PDF Translate] stdout: {}", stdout); - if !stderr.is_empty() { - println!("[PDF Translate] stderr: {}", stderr); - } - - if output.status.success() { - // 构建输出文件路径 - let mono_path = format!("{}/{}-mono.pdf", output_dir, filename_stem); - let dual_path = format!("{}/{}-dual.pdf", output_dir, filename_stem); - - Ok(serde_json::json!({ - "success": true, - "mono_pdf": mono_path, - "dual_pdf": dual_path, - "original_pdf": pdf_path, - })) + // Stream the sidecar output instead of blocking on .output(): this lets us + // forward per-page progress to the UI and log lines to the console live, + // so a long translation no longer looks frozen. + let mut child = command + .spawn() + .map_err(|e| format!("Failed to execute PDF sidecar '{}': {}", cmd, e))?; + + let child_stdout = child + .stdout + .take() + .ok_or_else(|| "PDF sidecar stdout unavailable".to_string())?; + let child_stderr = child + .stderr + .take() + .ok_or_else(|| "PDF sidecar stderr unavailable".to_string())?; + + // stdout: parse progress markers -> emit events + console log; pass the rest through. + let app_for_stdout = app_handle.clone(); + let stdout_handle = std::thread::spawn(move || { + let reader = BufReader::new(child_stdout); + for line in reader.lines().map_while(Result::ok) { + if let Some(rest) = line.strip_prefix("OPENKOTO_PROGRESS ") { + match serde_json::from_str::(rest) { + Ok(payload) => { + let current = payload.get("current").and_then(|v| v.as_i64()).unwrap_or(0); + let total = payload.get("total").and_then(|v| v.as_i64()).unwrap_or(0); + let percent = payload.get("percent").and_then(|v| v.as_i64()).unwrap_or(0); + println!( + "[PDF Translate] progress {}/{} ({}%)", + current, total, percent + ); + let _ = app_for_stdout.emit("pdf-translation-progress", payload); + } + Err(_) => println!("[PDF Sidecar] {}", line), + } } else { - Err(format!("PDF translation failed: {}", stderr)) + println!("[PDF Sidecar] {}", line); } } - Err(e) => Err(format!("Failed to execute PDF sidecar '{}': {}", cmd, e)), + }); + + // stderr: log live and accumulate so a failure still surfaces a useful message. + let stderr_handle = std::thread::spawn(move || { + let reader = BufReader::new(child_stderr); + let mut collected = String::new(); + for line in reader.lines().map_while(Result::ok) { + eprintln!("[PDF Sidecar:err] {}", line); + collected.push_str(&line); + collected.push('\n'); + } + collected + }); + + let status = child + .wait() + .map_err(|e| format!("Failed to wait for PDF sidecar: {}", e))?; + let _ = stdout_handle.join(); + let stderr_output = stderr_handle.join().unwrap_or_default(); + + if status.success() { + // Settle the UI at 100% once the files are written. + let _ = app_handle.emit( + "pdf-translation-progress", + serde_json::json!({"type": "progress", "current": 0, "total": 0, "percent": 100}), + ); + + let mono_path = format!("{}/{}-mono.pdf", output_dir, filename_stem); + let dual_path = format!("{}/{}-dual.pdf", output_dir, filename_stem); + + Ok(serde_json::json!({ + "success": true, + "mono_pdf": mono_path, + "dual_pdf": dual_path, + "original_pdf": pdf_path, + })) + } else { + Err(format!("PDF translation failed: {}", stderr_output)) } } diff --git a/textlingo-desktop/src/components/features/BookReader.test.tsx b/textlingo-desktop/src/components/features/BookReader.test.tsx index 3a3c499..f79650a 100644 --- a/textlingo-desktop/src/components/features/BookReader.test.tsx +++ b/textlingo-desktop/src/components/features/BookReader.test.tsx @@ -12,6 +12,10 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invokeMock(...args), })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn(), })); diff --git a/textlingo-desktop/src/components/features/BookReader.tsx b/textlingo-desktop/src/components/features/BookReader.tsx index a165dc2..30569b8 100644 --- a/textlingo-desktop/src/components/features/BookReader.tsx +++ b/textlingo-desktop/src/components/features/BookReader.tsx @@ -6,6 +6,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; import { Button } from "../ui/button"; import { ChevronLeft, BookOpen, PanelRightClose, PanelRightOpen, Languages, Loader2, Download, FileText, Split, File, Columns } from "lucide-react"; @@ -57,6 +58,8 @@ export function BookReader({ article, onBack }: BookReaderProps) { // PDF翻译状态 const [isTranslating, setIsTranslating] = useState(false); + // 翻译进度百分比(null 表示尚未收到进度) + const [translateProgress, setTranslateProgress] = useState(null); // 判断书籍类型 const isEpub = article.book_type === "epub"; @@ -186,7 +189,19 @@ export function BookReader({ article, onBack }: BookReaderProps) { const handlePdfTranslate = async () => { if (!article.book_path || isTranslating) return; + // 监听 Rust 转发的逐页翻译进度 + const unlistenProgress = await listen<{ current?: number; total?: number; percent?: number }>( + "pdf-translation-progress", + (event) => { + const percent = event.payload?.percent; + if (typeof percent === "number") { + setTranslateProgress(Math.max(0, Math.min(100, percent))); + } + } + ); + try { + setTranslateProgress(0); setIsTranslating(true); // 获取配置 @@ -244,7 +259,9 @@ export function BookReader({ article, onBack }: BookReaderProps) { console.error("[PDF Translate] Error:", error); alert(t("pdfTranslate.error", "翻译失败: {{error}}", { error: String(error) })); } finally { + unlistenProgress(); setIsTranslating(false); + setTranslateProgress(null); } }; @@ -342,7 +359,11 @@ export function BookReader({ article, onBack }: BookReaderProps) { )} - {isTranslating ? t("pdfTranslate.translating", "翻译中...") : t("pdfTranslate.button", "翻译全文")} + {isTranslating + ? (translateProgress !== null + ? t("pdfTranslate.translatingPercent", "翻译中 {{percent}}%", { percent: translateProgress }) + : t("pdfTranslate.translating", "翻译中...")) + : t("pdfTranslate.button", "翻译全文")} From 87b72c3374ce00121fd0b9b1679d1b3b49547a25 Mon Sep 17 00:00:00 2001 From: hikariming Date: Wed, 17 Jun 2026 09:08:12 +0800 Subject: [PATCH 2/2] Lower PDF translator rate-limit retries to 8 and bump v0.6.12 A 100-attempt retry on RateLimitError could keep a translation stuck for a very long time with little feedback. Cap it at 8 attempts so a persistently rate-limited run fails fast with a clear error. Co-Authored-By: Claude Opus 4.8 (1M context) --- textlingo-desktop/package.json | 2 +- .../pdf-sidecar/openkoto_pdf_translator/translator.py | 4 ++-- textlingo-desktop/src-tauri/Cargo.lock | 2 +- textlingo-desktop/src-tauri/Cargo.toml | 2 +- textlingo-desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/textlingo-desktop/package.json b/textlingo-desktop/package.json index e8f8ad6..86b647c 100644 --- a/textlingo-desktop/package.json +++ b/textlingo-desktop/package.json @@ -1,7 +1,7 @@ { "name": "openkoto-desktop", "private": true, - "version": "0.6.11", + "version": "0.6.12", "type": "module", "scripts": { "dev": "vite", diff --git a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/translator.py b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/translator.py index 0111b49..3b8653e 100644 --- a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/translator.py +++ b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/translator.py @@ -458,11 +458,11 @@ def __init__( @retry( retry=retry_if_exception_type(openai.RateLimitError), - stop=stop_after_attempt(100), + stop=stop_after_attempt(8), wait=wait_exponential(multiplier=1, min=1, max=15), before_sleep=lambda retry_state: logger.warning( f"RateLimitError, retrying in {retry_state.next_action.sleep} seconds... " - f"(Attempt {retry_state.attempt_number}/100)" + f"(Attempt {retry_state.attempt_number}/8)" ), ) def do_translate(self, text) -> str: diff --git a/textlingo-desktop/src-tauri/Cargo.lock b/textlingo-desktop/src-tauri/Cargo.lock index 2c69815..a2b0fbc 100644 --- a/textlingo-desktop/src-tauri/Cargo.lock +++ b/textlingo-desktop/src-tauri/Cargo.lock @@ -2859,7 +2859,7 @@ dependencies = [ [[package]] name = "openkoto-desktop" -version = "0.6.11" +version = "0.6.12" dependencies = [ "base64 0.22.1", "chrono", diff --git a/textlingo-desktop/src-tauri/Cargo.toml b/textlingo-desktop/src-tauri/Cargo.toml index 082a65a..67c930a 100644 --- a/textlingo-desktop/src-tauri/Cargo.toml +++ b/textlingo-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openkoto-desktop" -version = "0.6.11" +version = "0.6.12" description = "OpenKoto Desktop - AI-powered Article Reader" authors = ["OpenKoto Team"] edition = "2021" diff --git a/textlingo-desktop/src-tauri/tauri.conf.json b/textlingo-desktop/src-tauri/tauri.conf.json index 64659c2..e9fed50 100644 --- a/textlingo-desktop/src-tauri/tauri.conf.json +++ b/textlingo-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenKoto Desktop", - "version": "0.6.11", + "version": "0.6.12", "identifier": "com.openkoto.desktop", "build": { "beforeDevCommand": "npm run dev",