diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 4f2f652b..d23aa5f5 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -259,7 +259,7 @@ const BookshelfItem: React.FC = ({ }, }, translateInEpubEditor: { - text: '用 EPUB 审校器翻译', + text: '全书翻译', action: async () => { await openBookInReviewEditor(book, 'translate'); }, diff --git a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx index e440b0c5..1580b8e2 100644 --- a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx +++ b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx @@ -208,9 +208,25 @@ type TranslationFormState = { range_mode: 'limit' | 'all'; limit: number; temperature: number; + chunk_target: number; use_series_config: boolean; }; +type TranslationPlanPayload = { + source_count: number; + chunk_target: number; + chunk_count: number; + estimated_input_tokens: number; + chunks: Array<{ + id: string; + title?: string; + first_item_id: string; + last_item_id: string; + item_count: number; + estimated_input_tokens: number; + }>; +}; + const featureBlocks: Array<{ mode: LaunchMode; title: string; @@ -253,6 +269,7 @@ const emptyTranslationForm: TranslationFormState = { range_mode: 'limit', limit: 20, temperature: 0.2, + chunk_target: 24, use_series_config: false, }; @@ -1132,6 +1149,8 @@ function TranslateBlock({ const [source, setSource] = useState(null); const [form, setForm] = useState(emptyTranslationForm); const [job, setJob] = useState(null); + const [plan, setPlan] = useState(null); + const [recentJobs, setRecentJobs] = useState([]); const [starting, setStarting] = useState(false); const [gptLoaded, setGptLoaded] = useState(false); const pollTimerRef = useRef(null); @@ -1150,17 +1169,30 @@ function TranslateBlock({ setMessage(''); setGptLoaded(false); try { - const [sourcePayload, defaultsPayload] = await Promise.all([ + const [sourcePayload, defaultsPayload, jobsPayload] = await Promise.all([ sidecarApi( baseUrl, `/api/session/${encodeURIComponent(sessionId)}/translation-source`, ), sidecarApi(baseUrl, '/api/translation/defaults'), + sidecarApi<{ jobs?: TranslationJob[] }>( + baseUrl, + `/api/translation/jobs?session_id=${encodeURIComponent(sessionId)}`, + ), ]); const defaults = defaultsPayload.defaults || {}; const gpt = defaultsPayload.gpt || {}; const seriesConfig = sourcePayload.series_config || {}; setSource(sourcePayload); + setRecentJobs(jobsPayload.jobs || []); + const activeJob = (jobsPayload.jobs || []).find((item) => + ['pending', 'running', 'paused'].includes(item.status || ''), + ); + if (activeJob?.id) { + setJob(activeJob); + currentJobIdRef.current = activeJob.id; + pollTimerRef.current = window.setTimeout(() => void pollJob(activeJob.id!), 400); + } setForm({ base_url: gpt.base_url || '', model: gpt.model || '', @@ -1180,6 +1212,7 @@ function TranslateBlock({ range_mode: defaults.range_mode === 'all' ? 'all' : 'limit', limit: Number(defaults.limit || 20), temperature: Number(defaults.temperature ?? 0.2), + chunk_target: 24, use_series_config: Boolean( seriesConfig.translation_prompt || seriesConfig.format_prompt || @@ -1196,6 +1229,20 @@ function TranslateBlock({ } }, [baseUrl, sessionId]); + const previewPlan = useCallback(async () => { + if (!sessionId) return; + setMessage(''); + try { + const payload = await sidecarApi( + baseUrl, + `/api/session/${encodeURIComponent(sessionId)}/translation-plan?chunk_target=${encodeURIComponent(String(form.chunk_target))}`, + ); + setPlan(payload); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } + }, [baseUrl, form.chunk_target, sessionId]); + useEffect(() => { void loadTranslationData(); return clearPoll; @@ -1290,6 +1337,7 @@ function TranslateBlock({ range_mode: form.range_mode, limit: form.limit, temperature: form.temperature, + chunk_target: form.chunk_target, base_url: form.base_url, model: form.model, glossary_path: form.glossary_path, @@ -1320,12 +1368,30 @@ function TranslateBlock({ const progress = job?.progress || {}; const percent = Number(progress.percent || 0); const canOpenOutput = Boolean(job?.output_session_id); + const controlJob = async (action: 'pause' | 'resume' | 'cancel') => { + if (!job?.id) return; + try { + const payload = await sidecarApi( + baseUrl, + `/api/translation/jobs/${encodeURIComponent(job.id)}/${action}`, + { method: 'POST', body: '{}' }, + ); + setJob(payload.job || null); + if (action === 'resume') { + currentJobIdRef.current = job.id; + clearPoll(); + pollTimerRef.current = window.setTimeout(() => void pollJob(job.id!), 400); + } + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } + }; if (!sessionId) { return ( openExternalUrl(baseUrl)} /> @@ -1360,6 +1426,40 @@ function TranslateBlock({ + {recentJobs.length ? ( +
+

本书翻译任务

+
+ {recentJobs.slice(0, 6).map((item) => ( + + ))} +
+
+ ) : null} + {message ? (
{message} @@ -1374,7 +1474,7 @@ function TranslateBlock({ />
-
+
@@ -1494,6 +1633,16 @@ function TranslateBlock({ .filter(Boolean) .join('\n')} + {job?.id && !['completed', 'cancelled'].includes(job.status || '') ? ( +
+ {job.status === 'paused' || job.status === 'failed' ? ( + void controlJob('resume')}>恢复 + ) : ( + void controlJob('pause')}>暂停 + )} + void controlJob('cancel')}>取消 +
+ ) : null} {job?.logs?.length ? (
{job.logs diff --git a/apps/readest-app/src/services/reviewEditorService.ts b/apps/readest-app/src/services/reviewEditorService.ts index ff2047b9..a14c577a 100644 --- a/apps/readest-app/src/services/reviewEditorService.ts +++ b/apps/readest-app/src/services/reviewEditorService.ts @@ -114,6 +114,42 @@ export type ReviewGlossaryEntry = { clean_target?: string; }; +export type TranslationChunkPlan = { + id: string; + file: string; + title?: string; + first_item_id: string; + last_item_id: string; + item_count: number; + source_characters: number; + estimated_input_tokens: number; +}; + +export type TranslationPlanPayload = { + session_id: string; + source_count: number; + chunk_target: number; + chunk_count: number; + estimated_input_tokens: number; + chunks: TranslationChunkPlan[]; +}; + +export type TranslationJobSummary = { + id: string; + status: string; + source_session_id: string; + source_name?: string; + progress?: { + current?: number; + total?: number; + percent?: number; + failed?: number; + message?: string; + }; + output_session_id?: string; + error?: string; +}; + export type ReviewGlossaryPayload = { path?: string; exists?: boolean; @@ -372,3 +408,41 @@ export async function exportReviewedEpub( sessionId, ); } + +export async function loadTranslationPlan( + baseUrl: string, + sessionId: string, + chunkTarget: number, +): Promise { + return sidecarApi( + baseUrl, + `/api/session/${encodeURIComponent(sessionId)}/translation-plan?chunk_target=${encodeURIComponent(String(chunkTarget))}`, + ); +} + +export async function listTranslationJobs( + baseUrl: string, + sessionId?: string, +): Promise<{ jobs: TranslationJobSummary[]; count: number }> { + const suffix = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ''; + return sidecarApi(baseUrl, `/api/translation/jobs${suffix}`); +} + +export async function controlTranslationJob( + baseUrl: string, + jobId: string, + action: 'pause' | 'resume' | 'cancel', +): Promise<{ job: TranslationJobSummary }> { + return sidecarApi(baseUrl, `/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`, { + method: 'POST', + body: '{}', + }); +} + +export async function probeTranslationModels(baseUrl: string): Promise<{ + models: string[]; + configured_model: string; + configured_model_available: boolean; +}> { + return sidecarApi(baseUrl, '/api/gpt/models'); +} diff --git a/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md b/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md index 0294936c..7a3bdbbd 100644 --- a/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md +++ b/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md @@ -185,6 +185,12 @@ Prompt 硬规则: - `GET/POST /api/series//config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。 - `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url` 或 `model` 后复用已保存密钥,要更换端点或模型必须先保存配置。 - `GET /api/translation/jobs/` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。 +- `GET /api/translation/jobs` 返回所有书籍的公开任务摘要,支持 `session_id` 过滤;用于 Readest 多书任务中心。 +- `POST /api/library/series` 使用 `create|rename|assign|delete` 动作管理手动系列和书籍归类;人工归类写入 `library_overrides.json`,书架重建不得用 EPUB 元数据覆盖。 +- `/api/library` 与 `/api/sessions` 为每本书返回 `translation_status=untranslated|translating|translated` 和最近公开任务摘要;状态由持久任务派生,不使用浏览器内存判断。 +- `GET/POST /api/session//translation-plan` 按 XHTML 文件边界与目标正文块数返回 chunk 预览和 token 粗略估算,不启动翻译。 +- `POST /api/translation/jobs//pause|resume|cancel` 控制可靠任务;暂停和取消在当前 API 调用结束后生效,恢复复用逐正文块原子 checkpoint。 +- `GET /api/gpt/models` 从已保存的 OpenAI 兼容端点读取模型列表,只返回模型 ID;当前全书翻译默认模型为 `gpt-5.6-sol`。 翻译输入与提示词: diff --git a/apps/readest-app/tools/epub-review-editor/README.md b/apps/readest-app/tools/epub-review-editor/README.md index e33099ae..6656676a 100644 --- a/apps/readest-app/tools/epub-review-editor/README.md +++ b/apps/readest-app/tools/epub-review-editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.16.5` +当前版本:`0.17.0` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -69,6 +69,8 @@ `0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。 +`0.17.0` 启动 Readest 全书翻译工作台的可靠任务基座:默认模型更新为 `gpt-5.6-sol`,新增模型列表探测、分块计划预览、全局任务列表、暂停/恢复/取消和逐正文块原子断点保存。术语表继续使用 `mingcibiao.json` 的 JSON 对象格式,任务日志与公开 API 不返回密钥。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: diff --git a/apps/readest-app/tools/epub-review-editor/server.py b/apps/readest-app/tools/epub-review-editor/server.py index 91de7cdc..b3a5b64d 100644 --- a/apps/readest-app/tools/epub-review-editor/server.py +++ b/apps/readest-app/tools/epub-review-editor/server.py @@ -62,7 +62,7 @@ DEFAULT_REVIEW_ROOT = "epub_review_sessions" STATE_VERSION = 1 UPLOAD_DIR_NAME = "uploads" DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1" -DEFAULT_GPT_MODEL = "gpt-4o-mini" +DEFAULT_GPT_MODEL = "gpt-5.6-sol" DEFAULT_TRANSLATION_PROMPT = ( "你是日语轻小说到简体中文的审校重翻助手。只重翻当前目标语句/段落。" "目标是自然、流畅、忠实的简体中文轻小说文风。意义优先,再调整为中文语序;" @@ -104,6 +104,8 @@ TRANSLATION_LOG_LIMIT = 80 DEFAULT_TRANSLATION_LIMIT = 20 TRANSLATION_RANGE_LIMIT_MAX = 5000 TRANSLATION_CONTEXT_RADIUS = 1 +DEFAULT_TRANSLATION_CHUNK_TARGET = 24 +TRANSLATION_CHUNK_TARGET_MAX = 120 LIBRARY_DB_NAME = "library.json" ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity" @@ -1122,6 +1124,32 @@ def call_openai_compatible_chat( return content +def list_openai_compatible_models(config: dict[str, Any]) -> list[str]: + api_key = str(config.get("api_key") or "").strip() + if not api_key: + raise ValueError("尚未配置 GPT API Key") + base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL)) + req = urllib.request.Request( + f"{base_url}/models", + headers={"Authorization": f"Bearer {api_key}"}, + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + result = json.loads(response.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as exc: + detail = redact_secret(exc.read().decode("utf-8", errors="replace"), api_key)[:1200] + raise RuntimeError(f"模型列表请求失败:HTTP {exc.code} {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"模型列表请求失败:{redact_secret(exc.reason, api_key)}") from exc + rows = result.get("data") if isinstance(result, dict) else [] + return sorted( + str(row.get("id")) + for row in (rows or []) + if isinstance(row, dict) and str(row.get("id") or "").strip() + ) + + def session_root_for(review_root: Path, epub_path: Path) -> Path: digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10] return review_root / f"{safe_slug(epub_path.stem)}_{digest}" @@ -1147,6 +1175,10 @@ def library_db_path(review_root: Path) -> Path: return review_root / LIBRARY_DB_NAME +def library_overrides_path(review_root: Path) -> Path: + return review_root / "library_overrides.json" + + def series_config_path(review_root: Path, series_id: str) -> Path: return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json" @@ -1289,7 +1321,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]: updated_at = state.get("updated_at") or manifest.get("updated_at") or "" session_id = session_public_id(session_root) cover_asset_path = str(metadata.get("cover_asset_path") or "") - return { + summary = { "id": session_id, "version": version, "source_name": source_name, @@ -1316,6 +1348,13 @@ def summarize_session(session_root: Path) -> dict[str, Any]: "latest_export": latest_export, "reading_position": normalize_reading_position(state.get("reading_position", {})), } + review_root = session_root.parent + overrides = read_json(library_overrides_path(review_root), {}) + assignment = overrides.get("assignments", {}).get(session_id, {}) if isinstance(overrides, dict) else {} + if isinstance(assignment, dict) and assignment.get("series_id"): + summary["series_id"] = str(assignment["series_id"]) + summary["series"] = str(assignment.get("series") or summary["series"]) + return summary def rowTouched_py(row: dict[str, Any]) -> bool: @@ -1351,6 +1390,19 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]: def build_library_index(review_root: Path) -> dict[str, Any]: sessions = list_review_sessions(review_root) + jobs_by_session: dict[str, list[dict[str, Any]]] = {} + for job in list_translation_jobs(review_root): + source_session_id = str(job.get("source_session_id") or "") + if source_session_id: + jobs_by_session.setdefault(source_session_id, []).append(job) + for session in sessions: + session_jobs = jobs_by_session.get(str(session.get("id") or ""), []) + completed = next((job for job in session_jobs if job.get("status") == "completed"), None) + latest = session_jobs[0] if session_jobs else None + session["translation_status"] = ( + "translated" if completed else "translating" if latest else "untranslated" + ) + session["translation_job"] = latest series_map: dict[str, dict[str, Any]] = {} for session in sessions: series_id = str(session.get("series_id") or "uncategorized") @@ -1375,6 +1427,22 @@ def build_library_index(review_root: Path) -> dict[str, Any]: series["publishers"].add(session["publisher"]) if session.get("language"): series["languages"].add(session["language"]) + overrides = read_json(library_overrides_path(review_root), {}) + manual_series = overrides.get("series", {}) if isinstance(overrides, dict) else {} + if isinstance(manual_series, dict): + for series_id, title in manual_series.items(): + series_map.setdefault( + str(series_id), + { + "id": str(series_id), + "title": str(title), + "count": 0, + "books": [], + "authors": set(), + "publishers": set(), + "languages": set(), + }, + ) series_list = [] for item in series_map.values(): series_list.append( @@ -1398,6 +1466,44 @@ def build_library_index(review_root: Path) -> dict[str, Any]: return library +def save_library_series_overrides(review_root: Path, data: dict[str, Any]) -> dict[str, Any]: + current = read_json(library_overrides_path(review_root), {"series": {}, "assignments": {}}) + current.setdefault("series", {}) + current.setdefault("assignments", {}) + action = str(data.get("action") or "").strip() + if action in {"create", "rename"}: + title = str(data.get("title") or "").strip() + if not title: + raise ValueError("系列名称不能为空") + series_id = safe_slug(str(data.get("series_id") or title), max_len=120) + current["series"][series_id] = title + elif action == "assign": + session_id = str(data.get("session_id") or "").strip() + series_id = safe_slug(str(data.get("series_id") or ""), max_len=120) + if not session_id or not series_id: + raise ValueError("缺少 session_id 或 series_id") + session_root_from_id(review_root, session_id) + title = str(current["series"].get(series_id) or data.get("title") or series_id) + current["series"][series_id] = title + current["assignments"][session_id] = {"series_id": series_id, "series": title} + elif action == "delete": + series_id = safe_slug(str(data.get("series_id") or ""), max_len=120) + if not series_id: + raise ValueError("缺少 series_id") + if any( + assignment.get("series_id") == series_id + for assignment in current["assignments"].values() + if isinstance(assignment, dict) + ): + raise ValueError("系列仍有书籍,不能删除") + current["series"].pop(series_id, None) + else: + raise ValueError("不支持的系列操作") + current["updated_at"] = now_iso() + write_json_atomic(library_overrides_path(review_root), current) + return build_library_index(review_root) + + def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]: path = series_config_path(review_root, series_id) data = read_json(path, {}) @@ -2766,10 +2872,29 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]: "limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT), "glossary_path": settings.get("glossary_path", ""), "temperature": settings.get("temperature", 0.2), + "chunk_target": settings.get("chunk_target", DEFAULT_TRANSLATION_CHUNK_TARGET), }, } +def list_translation_jobs(review_root: Path) -> list[dict[str, Any]]: + jobs: list[dict[str, Any]] = [] + root = translation_jobs_root(review_root) + if not root.exists(): + return jobs + for child in root.iterdir(): + if not child.is_dir(): + continue + try: + job = read_translation_job(review_root, child.name) + except (OSError, ValueError, json.JSONDecodeError): + continue + if job: + jobs.append(public_translation_job(job)) + jobs.sort(key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), reverse=True) + return jobs + + def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: output_mode = str(data.get("output_mode") or "bilingual").strip().lower() if output_mode not in {"bilingual", "translated"}: @@ -2787,6 +2912,11 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A except (TypeError, ValueError) as exc: raise ValueError("temperature 必须是数字") from exc temperature = max(0.0, min(1.0, temperature)) + try: + chunk_target = int(data.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET) + except (TypeError, ValueError) as exc: + raise ValueError("每个 chunk 的目标正文块数必须是数字") from exc + chunk_target = max(1, min(chunk_target, TRANSLATION_CHUNK_TARGET_MAX)) def prompt_value(name: str) -> Any: if data.get(f"use_series_{name}"): return None @@ -2798,6 +2928,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A "range_mode": range_mode, "limit": limit, "temperature": temperature, + "chunk_target": chunk_target, "glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "", "translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")), "format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")), @@ -2805,6 +2936,44 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A } +def build_translation_plan( + items: list[dict[str, Any]], chunk_target: int = DEFAULT_TRANSLATION_CHUNK_TARGET +) -> list[dict[str, Any]]: + """Group source blocks without crossing XHTML files, preserving stable source order.""" + chunk_target = max(1, min(int(chunk_target), TRANSLATION_CHUNK_TARGET_MAX)) + chunks: list[dict[str, Any]] = [] + current: list[dict[str, Any]] = [] + current_file = "" + + def flush() -> None: + nonlocal current + if not current: + return + text_chars = sum(len(str(item.get("source_text") or "")) for item in current) + chunks.append( + { + "id": f"C{len(chunks) + 1:04d}", + "file": current[0].get("file", ""), + "title": current[0].get("document_title", ""), + "first_item_id": current[0].get("id", ""), + "last_item_id": current[-1].get("id", ""), + "item_count": len(current), + "source_characters": text_chars, + "estimated_input_tokens": max(1, round(text_chars / 1.7)), + } + ) + current = [] + + for item in items: + item_file = str(item.get("file") or "") + if current and (item_file != current_file or len(current) >= chunk_target): + flush() + current_file = item_file + current.append(item) + flush() + return chunks + + def html_looks_like_navigation(entry_name: str, text: str) -> bool: basename = posixpath.basename(entry_name).lower() if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename: @@ -3121,6 +3290,22 @@ def run_translation_job(review_root: Path, job_id: str) -> None: if not selected_items: raise ValueError("没有找到可翻译的日文正文块") + checkpoint_path = translation_job_root(review_root, job_id) / "translations.json" + checkpoint = read_json(checkpoint_path, {}) + if isinstance(checkpoint, dict): + saved_translations = checkpoint.get("translations") + if isinstance(saved_translations, dict): + translations.update( + { + str(key): str(value) + for key, value in saved_translations.items() + if key and isinstance(value, str) + } + ) + completed_ids = {item["id"] for item in selected_items if item["id"] in translations} + if completed_ids: + add_translation_job_log(job, f"已从断点恢复 {len(completed_ids)} 个正文块。") + job["progress"].update( { "total": len(selected_items), @@ -3134,6 +3319,44 @@ def run_translation_job(review_root: Path, job_id: str) -> None: failed_count = 0 errors: list[dict[str, Any]] = [] for index, item in enumerate(selected_items): + job = read_translation_job(review_root, job_id) or job + control = str(job.get("control") or "") + if control == "cancel": + job["status"] = "cancelled" + job["finished_at"] = now_iso() + job["progress"]["message"] = "任务已取消,断点已保留。" + add_translation_job_log(job, job["progress"]["message"]) + write_translation_job(review_root, job) + return + while control == "pause": + job["status"] = "paused" + job["progress"]["message"] = "任务已暂停,断点已保留。" + write_translation_job(review_root, job) + time.sleep(0.4) + job = read_translation_job(review_root, job_id) or job + control = str(job.get("control") or "") + if control == "cancel": + break + if control == "cancel": + job["status"] = "cancelled" + job["finished_at"] = now_iso() + job["progress"]["message"] = "任务已取消,断点已保留。" + write_translation_job(review_root, job) + return + if job.get("status") == "paused": + job["status"] = "running" + job["control"] = "" + add_translation_job_log(job, "任务已恢复。") + if item["id"] in translations: + job["progress"].update( + { + "current": index + 1, + "percent": round((index + 1) / max(1, len(selected_items)) * 100, 1), + "message": f"已从断点跳过 {item['id']}({index + 1}/{len(selected_items)})", + } + ) + write_translation_job(review_root, job) + continue job["progress"].update( { "current": index, @@ -3164,11 +3387,10 @@ def run_translation_job(review_root: Path, job_id: str) -> None: errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text}) translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】" add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning") - if index == 0 or (index + 1) % 5 == 0 or failed_count: - write_json_atomic( - translation_job_root(review_root, job_id) / "translations.json", - {"items": selected_items, "translations": translations, "errors": errors}, - ) + write_json_atomic( + checkpoint_path, + {"items": selected_items, "translations": translations, "errors": errors}, + ) job["progress"].update( { @@ -3406,6 +3628,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: def api_library(): return jsonify(build_library_index(review_root)) + @app.route("/api/library/series", methods=["POST"]) + def api_library_series(): + data = request.get_json(silent=True) or {} + try: + return jsonify(save_library_series_overrides(review_root, data)) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + @app.route("/api/series//config", methods=["GET", "POST"]) def api_series_config(series_id: str): series_id = safe_slug(series_id, max_len=120) @@ -3612,6 +3842,25 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: except ValueError as exc: return jsonify({"error": str(exc)}), 400 + @app.route("/api/gpt/models") + def api_gpt_models(): + session_root, _epub_path = session_from_request() + try: + config = read_scoped_gpt_config(review_root, session_root) + models = list_openai_compatible_models(config) + configured_model = str(config.get("model") or DEFAULT_GPT_MODEL) + return jsonify( + { + "models": models, + "configured_model": configured_model, + "configured_model_available": configured_model in models, + } + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 502 + @app.route("/api/glossary", methods=["GET", "POST"]) def api_glossary(): session_root, _epub_path = session_from_request() @@ -3748,6 +3997,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: except Exception as exc: return jsonify({"error": f"读取翻译源失败:{exc}"}), 500 + @app.route("/api/session//translation-plan", methods=["GET", "POST"]) + def api_translation_plan(session_id: str): + try: + source_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (source_root / "review_state" / "state.json").exists() or session_is_hidden(source_root): + return jsonify({"error": "会话不存在或已移除"}), 404 + data = request.get_json(silent=True) or {} + raw_target = data.get("chunk_target") or request.args.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET + try: + chunk_target = max(1, min(int(raw_target), TRANSLATION_CHUNK_TARGET_MAX)) + except (TypeError, ValueError): + return jsonify({"error": "chunk_target 必须是数字"}), 400 + items = extract_translation_source_items(source_root) + chunks = build_translation_plan(items, chunk_target) + return jsonify( + { + "session_id": session_id, + "source_count": len(items), + "chunk_target": chunk_target, + "chunk_count": len(chunks), + "estimated_input_tokens": sum(chunk["estimated_input_tokens"] for chunk in chunks), + "chunks": chunks, + } + ) + @app.route("/api/translation/start", methods=["POST"]) def api_translation_start(): data = request.get_json(silent=True) or {} @@ -3816,6 +4092,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: except Exception as exc: return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500 + @app.route("/api/translation/jobs") + def api_translation_jobs(): + jobs = list_translation_jobs(review_root) + session_id = str(request.args.get("session_id") or "").strip() + if session_id: + jobs = [job for job in jobs if job.get("source_session_id") == session_id] + return jsonify({"jobs": jobs, "count": len(jobs)}) + @app.route("/api/translation/jobs/") def api_translation_job(job_id: str): try: @@ -3826,6 +4110,43 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return jsonify({"error": "翻译任务不存在"}), 404 return jsonify({"job": public_translation_job(job)}) + @app.route("/api/translation/jobs//", methods=["POST"]) + def api_translation_job_action(job_id: str, action: str): + if action not in {"pause", "resume", "cancel"}: + return jsonify({"error": "不支持的任务操作"}), 404 + try: + job = read_translation_job(review_root, job_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not job: + return jsonify({"error": "翻译任务不存在"}), 404 + status = str(job.get("status") or "") + if action == "pause": + if status not in {"pending", "running"}: + return jsonify({"error": "当前任务不能暂停"}), 409 + job["control"] = "pause" + add_translation_job_log(job, "已请求暂停,当前 API 调用完成后生效。") + elif action == "cancel": + if status in {"completed", "failed", "cancelled"}: + return jsonify({"error": "当前任务不能取消"}), 409 + job["control"] = "cancel" + add_translation_job_log(job, "已请求取消,当前 API 调用完成后生效。") + else: + if status not in {"paused", "failed", "cancelled"}: + return jsonify({"error": "当前任务不能恢复"}), 409 + job["control"] = "" + job["status"] = "pending" + job["finished_at"] = "" + job["error"] = "" + add_translation_job_log(job, "已请求从断点恢复。") + write_translation_job(review_root, job) + thread = threading.Thread(target=run_translation_job, args=(review_root, job_id), daemon=True) + app.config["TRANSLATION_THREADS"][job_id] = thread + thread.start() + return jsonify({"job": public_translation_job(job)}) + write_translation_job(review_root, job) + return jsonify({"job": public_translation_job(job)}) + @app.route("/api/row//retranslate", methods=["POST"]) def api_retranslate_row(row_id: str): session_root, _epub_path = session_from_request() diff --git a/apps/readest-app/tools/epub-review-editor/static/index.html b/apps/readest-app/tools/epub-review-editor/static/index.html index 01aaaae0..43f4d450 100644 --- a/apps/readest-app/tools/epub-review-editor/static/index.html +++ b/apps/readest-app/tools/epub-review-editor/static/index.html @@ -4,13 +4,13 @@ 双语 EPUB 审校编辑器 - +
-

双语 EPUB 审校编辑器 v0.16.5

+

双语 EPUB 审校编辑器 v0.17.0

正在载入……

@@ -509,6 +509,6 @@ - + diff --git a/apps/readest-app/tools/epub-review-editor/test_full_book_translation.py b/apps/readest-app/tools/epub-review-editor/test_full_book_translation.py new file mode 100644 index 00000000..3f802600 --- /dev/null +++ b/apps/readest-app/tools/epub-review-editor/test_full_book_translation.py @@ -0,0 +1,126 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).with_name("server.py") +SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH) +server = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(server) + + +class FullBookTranslationTests(unittest.TestCase): + def test_default_model_targets_gpt_5_6_sol(self): + self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-5.6-sol") + + def test_chunk_plan_keeps_file_boundaries(self): + items = [ + {"id": "T00001", "file": "a.xhtml", "document_title": "A", "source_text": "一" * 17}, + {"id": "T00002", "file": "a.xhtml", "document_title": "A", "source_text": "二" * 17}, + {"id": "T00003", "file": "b.xhtml", "document_title": "B", "source_text": "三" * 17}, + ] + chunks = server.build_translation_plan(items, chunk_target=2) + self.assertEqual([chunk["item_count"] for chunk in chunks], [2, 1]) + self.assertEqual([chunk["file"] for chunk in chunks], ["a.xhtml", "b.xhtml"]) + self.assertEqual(chunks[0]["first_item_id"], "T00001") + self.assertEqual(chunks[0]["last_item_id"], "T00002") + + def test_glossary_roundtrip_keeps_legacy_object_shape(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + glossary_file = root / "mingcibiao.json" + server.write_json(root / "gpt_config.json", {"glossary_path": str(glossary_file)}) + payload = server.save_glossary( + root, + [ + {"source": "勇者", "target": "勇者"}, + {"source": "聖剣", "target": "圣剑(Excalibur)#保留有意义注音"}, + ], + ) + saved = server.read_json(glossary_file, {}) + self.assertEqual(saved["聖剣"], "圣剑(Excalibur)#保留有意义注音") + self.assertEqual(payload["count"], 2) + self.assertTrue(all(isinstance(value, str) for value in saved.values())) + + def test_job_list_never_exposes_api_key(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + job = { + "id": "tr_test", + "status": "running", + "source_session_id": "book", + "settings": {"api_key": "secret-value", "model": "gpt-5.6-sol"}, + "logs": [], + } + server.write_translation_job(root, job) + public = server.list_translation_jobs(root) + self.assertNotIn("secret-value", str(public)) + + def test_library_translation_status_is_derived_from_jobs(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + session = root / "book_session" + server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"}) + server.write_json( + server.session_manifest_path(session), + {"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}}, + ) + server.write_translation_job( + root, + { + "id": "tr_status", + "status": "running", + "source_session_id": session.name, + "settings": {}, + "logs": [], + }, + ) + library = server.build_library_index(root) + self.assertEqual(library["sessions"][0]["translation_status"], "translating") + + def test_manual_series_assignment_survives_library_rebuild(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + session = root / "book_session" + server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"}) + server.write_json( + server.session_manifest_path(session), + { + "id": session.name, + "source_name": "book.epub", + "source_epub": "book.epub", + "metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"}, + }, + ) + server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"}) + library = server.save_library_series_overrides( + root, + {"action": "assign", "session_id": session.name, "series_id": "manual"}, + ) + book = next(item for item in library["sessions"] if item["id"] == session.name) + self.assertEqual(book["series_id"], "manual") + self.assertEqual(book["series"], "手动系列") + rebuilt = server.build_library_index(root) + rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name) + self.assertEqual(rebuilt_book["series_id"], "manual") + + def test_models_endpoint_returns_only_public_ids(self): + with tempfile.TemporaryDirectory() as temp_dir: + app = server.create_app(Path(temp_dir)) + with patch.object(server, "read_scoped_gpt_config", return_value={ + "api_key": "secret-value", + "base_url": "https://example.test/v1", + "model": "gpt-5.6-sol", + }), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]): + response = app.test_client().get("/api/gpt/models") + self.assertEqual(response.status_code, 200) + body = response.get_json() + self.assertEqual(body["models"], ["gpt-5.6-sol"]) + self.assertNotIn("secret-value", str(body)) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/readest-app/tools/epub-review-editor/version.py b/apps/readest-app/tools/epub-review-editor/version.py index 2f7fd6da..a61dc9ea 100644 --- a/apps/readest-app/tools/epub-review-editor/version.py +++ b/apps/readest-app/tools/epub-review-editor/version.py @@ -1,4 +1,4 @@ -version = "0.16.5" +version = "0.17.0" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修",