Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion textlingo-desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openkoto-desktop",
"private": true,
"version": "0.6.11",
"version": "0.6.12",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
37 changes: 35 additions & 2 deletions textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import argparse
import json
import logging
import sys
from string import Template
Expand All @@ -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)
Expand Down Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion textlingo-desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion textlingo-desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
105 changes: 79 additions & 26 deletions textlingo-desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2979,7 +2979,8 @@ pub async fn translate_pdf_document(
base_url: Option<String>,
) -> Result<serde_json::Value, String> {
use crate::pdf_sidecar;
use std::process::Command;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};

println!(
"[PDF Translate] Starting translation: {} -> {}",
Expand Down Expand Up @@ -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::<serde_json::Value>(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))
}
}

Expand Down
2 changes: 1 addition & 1 deletion textlingo-desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions textlingo-desktop/src/components/features/BookReader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
23 changes: 22 additions & 1 deletion textlingo-desktop/src/components/features/BookReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,6 +58,8 @@ export function BookReader({ article, onBack }: BookReaderProps) {

// PDF翻译状态
const [isTranslating, setIsTranslating] = useState(false);
// 翻译进度百分比(null 表示尚未收到进度)
const [translateProgress, setTranslateProgress] = useState<number | null>(null);

// 判断书籍类型
const isEpub = article.book_type === "epub";
Expand Down Expand Up @@ -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);

// 获取配置
Expand Down Expand Up @@ -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);
}
};

Expand Down Expand Up @@ -342,7 +359,11 @@ export function BookReader({ article, onBack }: BookReaderProps) {
<Languages size={16} />
)}
<span className="hidden sm:inline">
{isTranslating ? t("pdfTranslate.translating", "翻译中...") : t("pdfTranslate.button", "翻译全文")}
{isTranslating
? (translateProgress !== null
? t("pdfTranslate.translatingPercent", "翻译中 {{percent}}%", { percent: translateProgress })
: t("pdfTranslate.translating", "翻译中..."))
: t("pdfTranslate.button", "翻译全文")}
</span>
</Button>

Expand Down