13 Commits

Author SHA1 Message Date
akai d4136c53c7 Merge remote-tracking branch 'origin/codex/full-book-translation' into codex/readest-web-translation-base
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
# Conflicts:
#	apps/readest-app/src/app/library/components/BookshelfItem.tsx
#	apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx
#	apps/readest-app/src/services/reviewEditorService.ts
#	apps/readest-app/tools/epub-review-editor/MAINTENANCE.md
#	apps/readest-app/tools/epub-review-editor/README.md
#	apps/readest-app/tools/epub-review-editor/static/index.html
#	apps/readest-app/tools/epub-review-editor/version.py
#	scripts/open-readest-latest.ps1
2026-07-10 15:34:51 +08:00
akai 03c6969ce6 Merge remote-tracking branch 'origin/main' into codex/readest-web-translation-base
# Conflicts:
#	apps/readest-app/src/app/api/review-editor/launch/route.ts
#	apps/readest-app/src/app/library/components/BookshelfItem.tsx
#	apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx
#	apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx
#	apps/readest-app/src/services/reviewEditorService.ts
2026-07-10 15:03:01 +08:00
an c3a3a1d035 Merge pull request 'Fix web dev review editor launch' (#4) from web-dev-bug-fixed into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Reviewed-on: #4
2026-07-10 05:17:32 +00:00
akai acb7dd4c93 fix: recover local Readest launcher 2026-07-10 12:52:06 +08:00
an 0d0732e579 fix: bootstrap local Readest dependencies 2026-07-10 12:44:43 +08:00
akai da756eed47 feat: secure inline review sidecar 2026-07-10 12:37:13 +08:00
an 3938c9c8bd chore: point launcher at full-book translation branch 2026-07-10 12:03:31 +08:00
an 64508fd336 feat: start reliable full-book translation workflow 2026-07-10 12:02:15 +08:00
akai 48383b3db6 fix: harden inline review web workflow 2026-07-10 11:56:25 +08:00
Codex a06b6183d7 Fix web dev review editor launch
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-10 00:42:10 +08:00
an 43ce37c61b Merge pull request 'Codex/bilingual epub filter' (#1) from codex/bilingual-epub-filter into main
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Publish Docker image / merge (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
PR checks / rust_lint (push) Has been cancelled
PR checks / build_web_app (push) Has been cancelled
PR checks / test_web_app (1) (push) Has been cancelled
PR checks / test_web_app (2) (push) Has been cancelled
PR checks / test_extensions (push) Has been cancelled
PR checks / build_tauri_app (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Deploy to vercel on merge / build_and_deploy (push) Has been cancelled
Reviewed-on: #1
Reviewed-by: akai <1085932718@qq.com>
2026-07-09 13:30:40 +00:00
an 545c56966f Merge branch 'main' into codex/bilingual-epub-filter
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 13:30:13 +00:00
an 2d97de18c1 Merge pull request 'Readest native inline review mode' (#3) from codex/readest-inline-review-mode into main
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Publish Docker image / merge (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
PR checks / rust_lint (push) Has been cancelled
PR checks / build_web_app (push) Has been cancelled
PR checks / test_web_app (1) (push) Has been cancelled
PR checks / test_web_app (2) (push) Has been cancelled
PR checks / test_extensions (push) Has been cancelled
PR checks / build_tauri_app (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Deploy to vercel on merge / build_and_deploy (push) Has been cancelled
Reviewed-on: #3
2026-07-09 13:29:24 +00:00
26 changed files with 2653 additions and 370 deletions
+2
View File
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
fixturesDir, fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub', '../../src/__tests__/fixtures/data/sample-alice.epub',
); );
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { zipSync, strToU8 } from 'fflate';
const target = process.argv[2];
if (!target) throw new Error('output path is required');
const files = {
mimetype: strToU8('application/epub+zip'),
'META-INF/container.xml': strToU8(`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`),
'OEBPS/content.opf': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="id">urn:readest:review-e2e</dc:identifier>
<dc:title>Readest Review E2E</dc:title><dc:language>zh-CN</dc:language>
</metadata>
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chapter"/></spine>
</package>`),
'OEBPS/chapter.xhtml': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body>
<p class="sourceText">これは校正テスト用の原文です。</p>
<p>这是网页审校测试译文。</p>
</body></html>`),
};
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, zipSync(files, { level: 0 }));
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '../fixtures/base';
import { REVIEW_EPUB } from '../fixtures/books';
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
test.beforeAll(() => {
execFileSync(
process.execPath,
[path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
{ stdio: 'inherit' },
);
});
test.afterAll(() => {
fs.rmSync(REVIEW_EPUB, { force: true });
});
test.describe('Inline review mode', () => {
test('opens the local sidecar, saves an edit, and resumes the same session', async ({
openBook,
page,
}) => {
test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
const reader = await openBook(REVIEW_EPUB);
await reader.revealHeader();
const toggle = page.getByRole('button', { name: 'Review Mode' });
await expect(toggle).toBeVisible();
await toggle.click();
const panel = page.getByRole('group', { name: '审校' });
await expect(panel).toBeVisible({ timeout: 120_000 });
await panel.getByRole('tab', { name: '整书翻译' }).click();
await expect(panel.getByRole('heading', { name: '整书翻译' })).toBeVisible();
await expect(panel.getByText(/个正文块/)).toBeVisible({ timeout: 30_000 });
await panel.getByRole('tab', { name: '逐段审校' }).click();
const editor = panel.locator('textarea').first();
await expect(editor).toHaveValue('这是网页审校测试译文。');
await editor.fill('这是已保存的网页审校测试译文。');
await panel.getByRole('button', { name: '保存', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('已保存');
await panel.getByRole('button', { name: 'Close' }).click();
await expect(panel).toBeHidden();
await reader.revealHeader();
await page.getByRole('button', { name: 'Disable Review Mode' }).click();
await reader.revealHeader();
await page.getByRole('button', { name: 'Review Mode' }).click();
await expect(panel).toBeVisible({ timeout: 120_000 });
await expect(panel.locator('textarea').first()).toHaveValue('这是已保存的网页审校测试译文。');
const downloadPromise = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出 EPUB' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.epub$/i);
});
});
+2 -2
View File
@@ -51,8 +51,8 @@
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs", "copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs", "copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs", "copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css", "copy-flatten-pdfjs-annotation-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css", "copy-flatten-pdfjs-text-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css", "copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css", "copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc", "copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
+1 -1
View File
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
"rustls", "rustls",
] } ] }
tauri-plugin-sentry = "0.5" tauri-plugin-sentry = "0.5"
rand = "0.8"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
cocoa = "0.25" cocoa = "0.25"
objc = "0.2.7" objc = "0.2.7"
objc-foundation = "0.1.1" objc-foundation = "0.1.1"
+95 -13
View File
@@ -1,8 +1,10 @@
use rand::RngCore;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use std::sync::Mutex; use std::sync::Mutex;
use std::{ use std::{
fs, fs,
fs::OpenOptions,
io::{Read, Write}, io::{Read, Write},
net::{SocketAddr, TcpStream}, net::{SocketAddr, TcpStream},
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -24,6 +26,7 @@ pub struct ReviewEditorLaunchResponse {
review_root: String, review_root: String,
version: String, version: String,
session_id: Option<String>, session_id: Option<String>,
access_token: String,
} }
struct CommandOutput { struct CommandOutput {
@@ -76,10 +79,12 @@ fn launch_epub_review_editor_sync(
review_root.to_string_lossy() review_root.to_string_lossy()
) )
})?; })?;
let access_token = ensure_access_token(&review_root)?;
let expected_version = read_expected_version(&tool_root); let expected_version = read_expected_version(&tool_root);
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root) { if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
let session_id = ensure_session_from_path(&url, epub_path.as_deref())?; {
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
return Ok(ReviewEditorLaunchResponse { return Ok(ReviewEditorLaunchResponse {
ok: true, ok: true,
url, url,
@@ -87,6 +92,7 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(), review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(), version: expected_version.unwrap_or_default(),
session_id, session_id,
access_token,
}); });
} }
@@ -111,13 +117,15 @@ fn launch_epub_review_editor_sync(
"127.0.0.1".to_string(), "127.0.0.1".to_string(),
"--port".to_string(), "--port".to_string(),
DEFAULT_PORT.to_string(), DEFAULT_PORT.to_string(),
"--access-token".to_string(),
access_token.clone(),
"--daemon".to_string(), "--daemon".to_string(),
], ],
Some(&tool_root), Some(&tool_root),
)?; )?;
let launched_url = extract_url(&output.stdout) let launched_url = extract_url(&output.stdout)
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root)) .or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}", "审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
@@ -126,7 +134,7 @@ fn launch_epub_review_editor_sync(
})?; })?;
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:"); let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref())?; let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
Ok(ReviewEditorLaunchResponse { Ok(ReviewEditorLaunchResponse {
ok: true, ok: true,
@@ -135,9 +143,57 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(), review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(), version: expected_version.unwrap_or_default(),
session_id, session_id,
access_token,
}) })
} }
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
let token_path = review_root.join(".access-token");
if let Ok(existing) = fs::read_to_string(&token_path) {
let token = existing.trim();
if !token.is_empty() {
return Ok(token.to_string());
}
}
let mut bytes = [0_u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let token = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let create_result = options
.open(&token_path)
.and_then(|mut file| file.write_all(token.as_bytes()));
match create_result {
Ok(()) => Ok(token),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
fs::read_to_string(&token_path)
.map(|value| value.trim().to_string())
.map_err(|read_err| {
format!("Failed to read review sidecar access token: {read_err}")
})
.and_then(|value| {
if value.is_empty() {
Err("Review sidecar access token file is empty.".to_string())
} else {
Ok(value)
}
})
}
Err(err) => Err(format!(
"Failed to create review sidecar access token: {err}"
)),
}
}
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> { fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") { if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw); let path = PathBuf::from(raw);
@@ -292,12 +348,16 @@ fn read_expected_version(tool_root: &Path) -> Option<String> {
Some(rest[..rest.find('"')?].to_string()) Some(rest[..rest.find('"')?].to_string())
} }
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Option<String> { fn find_running_editor(
expected_version: Option<&str>,
review_root: &Path,
access_token: &str,
) -> Option<String> {
let expected_root = review_root let expected_root = review_root
.canonicalize() .canonicalize()
.unwrap_or_else(|_| review_root.to_path_buf()); .unwrap_or_else(|_| review_root.to_path_buf());
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) { for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
let Some(payload) = request_json(port, "/api/version") else { let Some(payload) = request_json(port, "/api/version", access_token) else {
continue; continue;
}; };
let Some(version) = payload.get("version").and_then(Value::as_str) else { let Some(version) = payload.get("version").and_then(Value::as_str) else {
@@ -322,17 +382,19 @@ fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Op
None None
} }
fn request_json(port: u16, path: &str) -> Option<Value> { fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
let addr = SocketAddr::from(([127, 0, 0, 1], port)); let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?; let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800))); let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(800))); let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); let request = format!(
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).ok()?; stream.write_all(request.as_bytes()).ok()?;
read_http_json_response(stream) read_http_json_response(stream)
} }
fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> { fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Value, String> {
let port = parse_loopback_port(url)?; let port = parse_loopback_port(url)?;
let addr = SocketAddr::from(([127, 0, 0, 1], port)); let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3)) let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
@@ -340,7 +402,7 @@ fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
let request = format!( let request = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body.len(),
body body
); );
@@ -392,12 +454,16 @@ fn parse_loopback_port(url: &str) -> Result<u16, String> {
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})")) .map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
} }
fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option<String>, String> { fn ensure_session_from_path(
url: &str,
epub_path: Option<&str>,
access_token: &str,
) -> Result<Option<String>, String> {
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else { let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None); return Ok(None);
}; };
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string(); let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
let payload = post_json(url, "/api/session/from-path", &body)?; let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
Ok(payload Ok(payload
.get("id") .get("id")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -416,13 +482,29 @@ fn run_command(
Err(format!( Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}", "命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program, program,
args.join(" "), redact_command_args(args).join(" "),
output.stdout, output.stdout,
output.stderr output.stderr
)) ))
} }
} }
fn redact_command_args(args: &[String]) -> Vec<String> {
let mut redact_next = false;
args.iter()
.map(|arg| {
if redact_next {
redact_next = false;
return "[REDACTED]".to_string();
}
if arg == "--access-token" {
redact_next = true;
}
arg.clone()
})
.collect()
}
fn run_command_allow_failure( fn run_command_allow_failure(
program: &str, program: &str,
args: &[String], args: &[String],
@@ -1,4 +1,8 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: null }),
}));
import { __testing } from '@/app/reader/components/ReviewModeController'; import { __testing } from '@/app/reader/components/ReviewModeController';
import type { BookDoc } from '@/libs/document'; import type { BookDoc } from '@/libs/document';
@@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => {
expect(target.classList.contains('readest-review-active')).toBe(false); expect(target.classList.contains('readest-review-active')).toBe(false);
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true); expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
}); });
test('clears the target paragraph when a saved translation is empty', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文</p><p>旧译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '',
},
],
'R00001',
);
expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
});
}); });
@@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const h = vi.hoisted(() => ({
ask: vi.fn(),
setBookEnabled: vi.fn(),
reviewStore: {
books: {
'book-a': { enabled: true, loading: false, hasUnsavedEdit: true },
},
setActiveBookKey: vi.fn(),
setPanelVisible: vi.fn(),
setBookLoading: vi.fn(),
setBookError: vi.fn(),
setBookEnabled: vi.fn(),
setBookData: vi.fn(),
},
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }),
}));
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value }));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) =>
selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ setHoveredBookKey: vi.fn() }),
}));
vi.mock('@/store/reviewModeStore', () => {
const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) =>
selector ? selector(h.reviewStore) : h.reviewStore;
Object.assign(useReviewModeStore, { getState: () => h.reviewStore });
return { useReviewModeStore };
});
vi.mock('@/services/reviewEditorService', () => ({
canLaunchInlineReviewEditor: () => true,
launchInlineReviewEditor: vi.fn(),
loadInlineReviewData: vi.fn(),
}));
vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } }));
import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler';
describe('ReviewModeToggler unsaved edit guard', () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
h.reviewStore.setBookEnabled = h.setBookEnabled;
});
test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => {
h.ask.mockResolvedValue(false);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.ask).toHaveBeenCalledOnce());
expect(h.setBookEnabled).not.toHaveBeenCalled();
});
test('disables review mode after confirming the unsaved edit warning', async () => {
h.ask.mockResolvedValue(true);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false));
});
});
@@ -1,9 +1,17 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { ReviewRow } from '@/services/reviewEditorService';
import { import {
canLaunchInlineReviewEditor,
createReviewSessionFromPath, createReviewSessionFromPath,
downloadReviewedEpub,
exportReviewedEpub, exportReviewedEpub,
generateReviewFeedback, generateReviewFeedback,
isReviewEditDirty,
launchInlineReviewEditor,
listTranslationJobs,
loadTranslationPlan,
controlTranslationJob,
loadInlineReviewData, loadInlineReviewData,
loadReviewGlossary, loadReviewGlossary,
saveReviewGptConfig, saveReviewGptConfig,
@@ -38,14 +46,165 @@ const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
}; };
describe('reviewEditorService', () => { describe('reviewEditorService', () => {
test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
});
test('rejects a launch response when the Next route returns an HTTP error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ ok: false, error: '本地 Web 审校入口未启用' }, { status: 404 }),
),
);
await expect(
launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
} as never,
{ format: 'EPUB' } as never,
false,
),
).rejects.toThrow('本地 Web 审校入口未启用');
});
test('uploads the current web book to the local sidecar before creating a session', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = input.toString();
if (url === '/api/review-editor/launch') {
return Response.json({
ok: true,
url: 'http://127.0.0.1:5177',
accessToken: 'sidecar-token',
});
}
return Response.json({ id: 'session-web' });
});
vi.stubGlobal('fetch', fetchMock);
const launched = await launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => null,
loadBookContent: async () => ({
file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }),
}),
} as never,
{ format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never,
false,
);
expect(launched.sessionId).toBe('session-web');
expect(fetchMock).toHaveBeenCalledTimes(2);
const uploadCall = fetchMock.mock.calls[1] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
expect(uploadCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
});
test('downloads an exported EPUB in web mode with its server filename', async () => {
const click = vi.fn();
const remove = vi.fn();
const anchor = { click, remove, href: '', download: '' };
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
const fetchMock = vi.fn(
async () => new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
);
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
await downloadReviewedEpub(
'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
'sidecar-token',
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/downloads/export/reviewed.epub'),
expect.objectContaining({
headers: { Authorization: 'Bearer sidecar-token' },
}),
);
expect(anchor.href).toBe('blob:reviewed-epub');
expect(anchor.download).toBe('reviewed.epub');
expect(click).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
});
test('detects unsaved row fields against the selected row baseline', () => {
const row = {
current_html: '译文',
cn_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
} as ReviewRow;
expect(
isReviewEditDirty(row, {
current_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(false);
expect(
isReviewEditDirty(row, {
current_html: '修改后译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(true);
expect(
isReviewEditDirty(
{ ...row, current_html: '' },
{
current_html: '',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
},
),
).toBe(false);
});
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => { test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
const fetchMock = stubJsonFetch(); const fetchMock = stubJsonFetch();
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a'); await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a', 'sidecar-token');
const url = new URL(firstFetchUrl(fetchMock)); const url = new URL(firstFetchUrl(fetchMock));
expect(url.pathname).toBe('/api/rows'); expect(url.pathname).toBe('/api/rows');
expect(url.searchParams.get('session_id')).toBe('session-a'); expect(url.searchParams.get('session_id')).toBe('session-a');
const firstCall = fetchMock.mock.calls[0] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(firstCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
}); });
test('row operations are scoped to the current review session', async () => { test('row operations are scoped to the current review session', async () => {
@@ -118,4 +277,28 @@ describe('reviewEditorService', () => {
expect(url.searchParams.get('session_id')).toBe('session-a'); expect(url.searchParams.get('session_id')).toBe('session-a');
} }
}); });
test('translation APIs keep bearer auth and current-book session scope', async () => {
const fetchMock = stubJsonFetch();
await loadTranslationPlan('http://127.0.0.1:5177', 'session-a', 24, 'sidecar-token');
await listTranslationJobs('http://127.0.0.1:5177', 'session-a', 'sidecar-token');
await controlTranslationJob(
'http://127.0.0.1:5177',
'session-a',
'tr_1',
'pause',
'sidecar-token',
);
expect(fetchMock).toHaveBeenCalledTimes(3);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const url = new URL(call[0].toString());
expect(url.searchParams.get('session_id')).toBe('session-a');
expect(call[1]?.headers).toMatchObject({ Authorization: 'Bearer sidecar-token' });
}
});
}); });
@@ -48,6 +48,19 @@ describe('reviewModeStore', () => {
expect(state.books['book-a']?.selectedRowId).toBe('R00001'); expect(state.books['book-a']?.selectedRowId).toBe('R00001');
}); });
test('reopens the panel for the selected row without clearing its unsaved flag', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
useReviewModeStore.getState().setBookUnsavedEdit('book-a', true);
useReviewModeStore.getState().setPanelVisible(false);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
const state = useReviewModeStore.getState();
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.hasUnsavedEdit).toBe(true);
});
test('updates one review row after save', () => { test('updates one review row after save', () => {
useReviewModeStore.getState().setBookData('book-a', { useReviewModeStore.getState().setBookData('book-a', {
rows: [ rows: [
@@ -1,4 +1,5 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
@@ -27,6 +28,7 @@ type LaunchResponse = {
reused?: boolean; reused?: boolean;
reviewRoot?: string; reviewRoot?: string;
version?: string; version?: string;
accessToken?: string;
error?: string; error?: string;
}; };
@@ -39,6 +41,28 @@ const appRoot = () => process.cwd();
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor'); const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
const reviewRoot = () => const reviewRoot = () =>
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions')); path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
const accessTokenPath = () => path.join(reviewRoot(), '.access-token');
const ensureAccessToken = () => {
fs.mkdirSync(reviewRoot(), { recursive: true });
try {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
} catch {
// Create the token below.
}
const token = crypto.randomBytes(32).toString('hex');
try {
fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
return token;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
}
throw error;
}
};
const venvRoot = () => path.join(toolRoot(), '.venv'); const venvRoot = () => path.join(toolRoot(), '.venv');
const venvPython = () => const venvPython = () =>
@@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => {
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:'); const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> => const requestJson = (
port: number,
pathname: string,
accessToken: string,
): Promise<Record<string, unknown> | null> =>
new Promise((resolve) => { new Promise((resolve) => {
const req = http.get( const req = http.get(
{ {
@@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
port, port,
path: pathname, path: pathname,
timeout: 800, timeout: 800,
headers: { Authorization: `Bearer ${accessToken}` },
}, },
(res) => { (res) => {
let body = ''; let body = '';
@@ -126,12 +155,18 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
req.on('error', () => resolve(null)); req.on('error', () => resolve(null));
}); });
const findRunningEditor = async () => { const findRunningEditor = async (accessToken: string) => {
const expectedVersion = readExpectedVersion(); const expectedVersion = readExpectedVersion();
const expectedReviewRoot = path.resolve(reviewRoot());
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) { for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
const payload = await requestJson(port, '/api/version'); const payload = await requestJson(port, '/api/version', accessToken);
if (!payload) continue; if (!payload) continue;
if (!expectedVersion || payload['version'] === expectedVersion) { const runningReviewRoot =
typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
if (
(!expectedVersion || payload['version'] === expectedVersion) &&
runningReviewRoot === expectedReviewRoot
) {
return `http://127.0.0.1:${port}`; return `http://127.0.0.1:${port}`;
} }
} }
@@ -237,7 +272,8 @@ async function launchEditor(): Promise<LaunchResponse> {
}; };
} }
const runningUrl = await findRunningEditor(); const accessToken = ensureAccessToken();
const runningUrl = await findRunningEditor(accessToken);
if (runningUrl) { if (runningUrl) {
return { return {
ok: true, ok: true,
@@ -245,6 +281,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: true, reused: true,
reviewRoot: reviewRoot(), reviewRoot: reviewRoot(),
version: readExpectedVersion(), version: readExpectedVersion(),
accessToken,
}; };
} }
@@ -263,6 +300,8 @@ async function launchEditor(): Promise<LaunchResponse> {
'127.0.0.1', '127.0.0.1',
'--port', '--port',
String(DEFAULT_PORT), String(DEFAULT_PORT),
'--access-token',
accessToken,
'--daemon', '--daemon',
], ],
{ cwd: bundledToolRoot, timeout: 45_000 }, { cwd: bundledToolRoot, timeout: 45_000 },
@@ -272,7 +311,7 @@ async function launchEditor(): Promise<LaunchResponse> {
} }
const launchedUrl = normalizeLoopbackUrl( const launchedUrl = normalizeLoopbackUrl(
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()), /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
); );
if (!launchedUrl) { if (!launchedUrl) {
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
@@ -284,6 +323,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: false, reused: false,
reviewRoot: reviewRoot(), reviewRoot: reviewRoot(),
version: readExpectedVersion(), version: readExpectedVersion(),
accessToken,
}; };
} catch (error) { } catch (error) {
return { return {
@@ -0,0 +1,361 @@
import { Download, Pause, Play, RefreshCw, Square, Sparkles } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
controlTranslationJob,
downloadReviewedEpub,
listTranslationJobs,
loadTranslationDefaults,
loadTranslationJob,
loadTranslationPlan,
loadTranslationSource,
startFullBookTranslation,
type TranslationJobSummary,
type TranslationPlanPayload,
type TranslationSourcePayload,
} from '@/services/reviewEditorService';
type TranslationSettings = {
output_mode: 'bilingual' | 'translated';
range_mode: 'all' | 'limit';
limit: number;
temperature: number;
chunk_target: number;
};
const defaultSettings: TranslationSettings = {
output_mode: 'bilingual',
range_mode: 'limit',
limit: 20,
temperature: 0.2,
chunk_target: 24,
};
const isRunningJob = (job: TranslationJobSummary | null) =>
job ? ['pending', 'running'].includes(job.status) : false;
const blocksNewJob = (job: TranslationJobSummary | null) =>
job ? ['pending', 'running', 'paused'].includes(job.status) : false;
const ActionButton = ({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<button
type='button'
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-3'
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
const FullBookTranslationPanel = ({
baseUrl,
sessionId,
accessToken,
}: {
baseUrl: string;
sessionId: string;
accessToken: string;
}) => {
const [settings, setSettings] = useState(defaultSettings);
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
const [job, setJob] = useState<TranslationJobSummary | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const pollRef = useRef<number | null>(null);
const defaultsAppliedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollRef.current !== null) window.clearInterval(pollRef.current);
pollRef.current = null;
}, []);
const refreshJob = useCallback(
async (jobId: string) => {
const payload = await loadTranslationJob(baseUrl, sessionId, jobId, accessToken);
setJob(payload.job);
if (!isRunningJob(payload.job)) stopPolling();
},
[accessToken, baseUrl, sessionId, stopPolling],
);
const startPolling = useCallback(
(jobId: string) => {
stopPolling();
pollRef.current = window.setInterval(() => {
void refreshJob(jobId).catch((error) => {
stopPolling();
setMessage(error instanceof Error ? error.message : String(error));
});
}, 1200);
},
[refreshJob, stopPolling],
);
const loadOverview = useCallback(async () => {
setBusy(true);
setMessage('');
try {
const [sourcePayload, defaultsPayload, planPayload, jobsPayload] = await Promise.all([
loadTranslationSource(baseUrl, sessionId, accessToken),
loadTranslationDefaults(baseUrl, sessionId, accessToken),
loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken),
listTranslationJobs(baseUrl, sessionId, accessToken),
]);
setSource(sourcePayload);
setPlan(planPayload);
if (defaultsPayload.defaults && !defaultsAppliedRef.current) {
defaultsAppliedRef.current = true;
setSettings((current) => ({
...current,
output_mode: defaultsPayload.defaults?.output_mode || current.output_mode,
range_mode: defaultsPayload.defaults?.range_mode || current.range_mode,
limit: defaultsPayload.defaults?.limit || current.limit,
temperature: defaultsPayload.defaults?.temperature ?? current.temperature,
chunk_target: defaultsPayload.defaults?.chunk_target || current.chunk_target,
}));
}
const latest = jobsPayload.jobs[0] || null;
setJob(latest);
if (latest && isRunningJob(latest)) startPolling(latest.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}, [accessToken, baseUrl, sessionId, settings.chunk_target, startPolling]);
useEffect(() => {
void loadOverview();
return stopPolling;
}, [loadOverview, stopPolling]);
const refreshPlan = async () => {
setBusy(true);
setMessage('');
try {
setPlan(await loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken));
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const start = async () => {
if (
settings.range_mode === 'all' &&
!window.confirm('整书翻译会连续调用已配置的 GPT API。确认开始吗?')
) {
return;
}
setBusy(true);
setMessage('');
try {
const payload = await startFullBookTranslation(baseUrl, sessionId, settings, accessToken);
setJob(payload.job);
startPolling(payload.job.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const control = async (action: 'pause' | 'resume' | 'cancel') => {
if (!job) return;
setBusy(true);
setMessage('');
try {
const payload = await controlTranslationJob(baseUrl, sessionId, job.id, action, accessToken);
setJob(payload.job);
startPolling(job.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const outputUrl = job?.output_download_url
? new URL(job.output_download_url, baseUrl).toString()
: '';
return (
<div className='grid gap-3'>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'></h3>
<p className='text-base-content/60 text-xs'>
{source ? `${source.source_count} 个正文块` : '正在读取翻译源'}
</p>
</div>
<ActionButton label='刷新翻译状态' onClick={() => void loadOverview()} disabled={busy}>
<RefreshCw className='h-4 w-4' />
</ActionButton>
</div>
<div className='grid grid-cols-2 gap-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={settings.output_mode}
onChange={(event) =>
setSettings((current) => ({
...current,
output_mode: event.target.value as TranslationSettings['output_mode'],
}))
}
>
<option value='bilingual'></option>
<option value='translated'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={settings.range_mode}
onChange={(event) =>
setSettings((current) => ({
...current,
range_mode: event.target.value as TranslationSettings['range_mode'],
}))
}
>
<option value='limit'></option>
<option value='all'></option>
</select>
</label>
{settings.range_mode === 'limit' ? (
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
type='number'
min={1}
max={5000}
value={settings.limit}
onChange={(event) =>
setSettings((current) => ({ ...current, limit: Number(event.target.value) }))
}
/>
</label>
) : null}
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
type='number'
min={1}
max={120}
value={settings.chunk_target}
onChange={(event) =>
setSettings((current) => ({
...current,
chunk_target: Number(event.target.value),
}))
}
onBlur={() => void refreshPlan()}
/>
</label>
</div>
{plan ? (
<p className='text-base-content/70 text-xs'>
{plan.chunk_count} · {plan.estimated_input_tokens.toLocaleString()}
token · XHTML
</p>
) : null}
<button
type='button'
className='btn btn-primary h-auto min-h-9 rounded-md px-3 py-2 text-sm'
onClick={() => void start()}
disabled={busy || blocksNewJob(job) || !source?.source_count}
>
<Sparkles className='h-4 w-4' />
{settings.range_mode === 'all' ? '开始整书翻译' : '开始试译'}
</button>
</section>
{job ? (
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'> {job.id}</h3>
<p className='text-base-content/60 text-xs break-words'>
{job.status} · {job.progress?.current || 0}/{job.progress?.total || 0}
</p>
</div>
<span className='text-sm font-semibold'>{job.progress?.percent || 0}%</span>
</div>
<progress
className='progress progress-primary h-2 w-full'
value={job.progress?.percent || 0}
max={100}
/>
{job.progress?.message ? (
<p className='text-base-content/70 text-xs break-words'>{job.progress.message}</p>
) : null}
{job.error ? <p className='text-error text-xs break-words'>{job.error}</p> : null}
<div className='flex flex-wrap gap-2'>
{job.status === 'running' || job.status === 'pending' ? (
<ActionButton label='暂停翻译' onClick={() => void control('pause')} disabled={busy}>
<Pause className='h-4 w-4' />
</ActionButton>
) : null}
{['paused', 'failed', 'cancelled'].includes(job.status) ? (
<ActionButton
label='从断点恢复'
onClick={() => void control('resume')}
disabled={busy}
>
<Play className='h-4 w-4' />
</ActionButton>
) : null}
{!['completed', 'failed', 'cancelled'].includes(job.status) ? (
<ActionButton label='取消翻译' onClick={() => void control('cancel')} disabled={busy}>
<Square className='h-4 w-4' />
</ActionButton>
) : null}
{outputUrl ? (
<ActionButton
label='下载翻译 EPUB'
onClick={() => void downloadReviewedEpub(outputUrl, accessToken)}
>
<Download className='h-4 w-4' />
</ActionButton>
) : null}
</div>
</section>
) : null}
{message ? (
<div
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
role='status'
>
{message}
</div>
) : null}
</div>
);
};
export default FullBookTranslationPanel;
@@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateBackToLibrary(); navigateBackToLibrary();
}; };
const handleCloseReaderToLibrary = () => { const handleCloseReaderToLibrary = async () => {
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
return handleCloseBooks(true); return handleCloseBooks(true);
}; };
@@ -230,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// SPA navigation in the main window (or on web) keeps the webview alive: // SPA navigation in the main window (or on web) keeps the webview alive:
// TTS may continue headless. Non-main Tauri windows close their webview // TTS may continue headless. Non-main Tauri windows close their webview
// below, but their per-window TTS dies with the window either way. // below, but their per-window TTS dies with the window either way.
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
handleCloseBooks(true); handleCloseBooks(true);
if (isTauriAppPlatform()) { if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow(); const currentWindow = getCurrentWindow();
@@ -250,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// Header X / pane close: an SPA-side close on web and the main window. // Header X / pane close: an SPA-side close on web and the main window.
// The Tauri reader-window branches below destroy their webview, which // The Tauri reader-window branches below destroy their webview, which
// takes the per-window TTS with it either way. // takes the per-window TTS with it either way.
const review = useReviewModeStore.getState().books[bookKey];
if (
review?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
) {
return;
}
saveConfigAndCloseBook(bookKey, true); saveConfigAndCloseBook(bookKey, true);
if (sideBarBookKey === bookKey) { if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey)); setSideBarBookKey(getNextBookKey(sideBarBookKey));
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import type { BookDoc } from '@/libs/document'; import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService'; import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore'; import { useReaderStore } from '@/store/readerStore';
@@ -147,7 +148,7 @@ const markParagraph = (
htmlElement.setAttribute(ROLE_ATTR, role); htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target'); htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active); htmlElement.classList.toggle('readest-review-active', active);
if (role === 'target' && row.current_html) { if (role === 'target') {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) { if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML); htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
} }
@@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey, bookKey,
bookDoc, bookDoc,
}) => { }) => {
const { appService } = useEnv();
const view = useReaderStore((state) => state.viewStates[bookKey]?.view); const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled); const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows); const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
@@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
selectedRowIdRef.current = selectedRowId; selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]); }, [selectedRowId]);
const selectReviewRow = async (rowId: string) => {
const state = useReviewModeStore.getState();
const previousBookKey = state.activeBookKey;
const current = state.books[previousBookKey || ''];
if (
current?.hasUnsavedEdit &&
(state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
) {
return;
}
if (previousBookKey && current?.hasUnsavedEdit) {
state.setBookUnsavedEdit(previousBookKey, false);
}
selectRow(bookKey, rowId);
};
useEffect(() => { useEffect(() => {
if (!view) return; if (!view) return;
@@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
if ('stopImmediatePropagation' in event) { if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
selectRow(bookKey, rowId); void selectReviewRow(rowId);
}; };
const handleSelectionEnd = (event: Event) => { const handleSelectionEnd = (event: Event) => {
@@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
window.setTimeout(() => { window.setTimeout(() => {
const target = selectedReviewTarget(doc); const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR); const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) selectRow(bookKey, rowId); if (rowId) void selectReviewRow(rowId);
}, 0); }, 0);
}; };
@@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
clearReviewMarks(doc); clearReviewMarks(doc);
} }
}; };
}, [bookDoc, bookKey, enabled, rows, selectRow, view]); }, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => { useEffect(() => {
if (!view || !enabled || !rows.length) return; if (!view || !enabled || !rows.length) return;
@@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore'; import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore'; import { useReviewModeStore } from '@/store/reviewModeStore';
import { eventDispatcher } from '@/utils/event'; import { eventDispatcher } from '@/utils/event';
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService'; import {
canLaunchInlineReviewEditor,
launchInlineReviewEditor,
loadInlineReviewData,
} from '@/services/reviewEditorService';
interface ReviewModeTogglerProps { interface ReviewModeTogglerProps {
bookKey: string; bookKey: string;
@@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const enabled = !!reviewState?.enabled; const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading; const loading = !!reviewState?.loading;
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null; if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => { const handleToggleReviewMode = async () => {
if (appService?.isMobile) { if (appService?.isMobile) {
@@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
} }
if (enabled) { if (enabled) {
if (
reviewState?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
) {
return;
}
setBookEnabled(bookKey, false); setBookEnabled(bookKey, false);
return; return;
} }
@@ -49,15 +60,34 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
return; return;
} }
const activeState = useReviewModeStore.getState();
const activeReview = activeState.books[activeState.activeBookKey || ''];
if (
activeState.activeBookKey !== bookKey &&
activeReview?.hasUnsavedEdit &&
appService &&
!(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
) {
return;
}
if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
}
setActiveBookKey(bookKey); setActiveBookKey(bookKey);
setPanelVisible(true); setPanelVisible(true);
setBookLoading(bookKey, true); setBookLoading(bookKey, true);
try { try {
const launch = await launchInlineReviewEditor(appService, bookData.book); const launch = await launchInlineReviewEditor(appService, bookData.book);
const data = await loadInlineReviewData(launch.url, launch.sessionId); const data = await loadInlineReviewData(
launch.url,
launch.sessionId,
launch.accessToken,
);
setBookData(bookKey, { setBookData(bookKey, {
baseUrl: launch.url, baseUrl: launch.url,
accessToken: launch.accessToken,
sessionId: launch.sessionId, sessionId: launch.sessionId,
reviewRoot: launch.reviewRoot, reviewRoot: launch.reviewRoot,
version: launch.version, version: launch.version,
@@ -91,7 +121,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
onClick={handleToggleReviewMode} onClick={handleToggleReviewMode}
disabled={loading} disabled={loading}
label={enabled ? _('Disable Review Mode') : _('Review Mode')} label={enabled ? _('Disable Review Mode') : _('Review Mode')}
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')} className={clsx(enabled && 'bg-base-300/50')}
/> />
); );
}; };
@@ -25,12 +25,14 @@ import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore'; import { useReviewModeStore } from '@/store/reviewModeStore';
import { useThemeStore } from '@/store/themeStore'; import { useThemeStore } from '@/store/themeStore';
import { getPanelTopInset } from '@/utils/insets'; import { getPanelTopInset } from '@/utils/insets';
import { openExternalUrl } from '@/utils/open';
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag'; import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize'; import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
import FullBookTranslationPanel from './FullBookTranslationPanel';
import { import {
downloadReviewedEpub,
exportReviewedEpub, exportReviewedEpub,
generateReviewFeedback, generateReviewFeedback,
isReviewEditDirty,
loadReviewGlossary, loadReviewGlossary,
retranslateReviewRow, retranslateReviewRow,
saveReviewGlossary, saveReviewGlossary,
@@ -488,6 +490,7 @@ const ReviewPanel: React.FC = () => {
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin); const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth); const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight); const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit);
const updateRow = useReviewModeStore((state) => state.updateRow); const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData); const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) => const bookState = useReviewModeStore((state) =>
@@ -508,7 +511,9 @@ const ReviewPanel: React.FC = () => {
const [exportInfo, setExportInfo] = useState(''); const [exportInfo, setExportInfo] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false); const [retranslating, setRetranslating] = useState(false);
const [exporting, setExporting] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [panelMode, setPanelMode] = useState<'review' | 'translate'>('review');
const isMobile = useIsMobileViewport(); const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({ const panelTopInset = getPanelTopInset({
isMobile, isMobile,
@@ -527,6 +532,19 @@ const ReviewPanel: React.FC = () => {
[bookState?.rows, bookState?.selectedRowId], [bookState?.rows, bookState?.selectedRowId],
); );
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl); const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
const hasUnsavedEdit = Boolean(selectedRow && isReviewEditDirty(selectedRow, edit));
useEffect(() => {
if (!activeBookKey) return;
setBookUnsavedEdit(activeBookKey, hasUnsavedEdit);
}, [activeBookKey, hasUnsavedEdit, setBookUnsavedEdit]);
useEffect(() => {
if (!hasUnsavedEdit) return;
const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [hasUnsavedEdit]);
const bookTitle = activeBookKey const bookTitle = activeBookKey
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校' ? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
@@ -544,7 +562,7 @@ const ReviewPanel: React.FC = () => {
return; return;
} }
setEdit({ setEdit({
current_html: selectedRow.current_html || selectedRow.cn_html || '', current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '',
marked: Boolean(selectedRow.marked), marked: Boolean(selectedRow.marked),
issue_type: selectedRow.issue_type || '', issue_type: selectedRow.issue_type || '',
severity: selectedRow.severity || '', severity: selectedRow.severity || '',
@@ -574,6 +592,17 @@ const ReviewPanel: React.FC = () => {
togglePanelPin(); togglePanelPin();
}; };
const closePanel = async () => {
if (
hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?'))
) {
return;
}
setPanelVisible(false);
};
const floatingPanelEnabled = !isPanelPinned && !isMobile; const floatingPanelEnabled = !isPanelPinned && !isMobile;
const { const {
panelPosition, panelPosition,
@@ -666,7 +695,7 @@ const ReviewPanel: React.FC = () => {
setMessage(''); setMessage('');
try { try {
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit); const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html); const savedHtml = payload.current_html ?? sanitizeInlineHtml(edit.current_html);
const nextRow = { const nextRow = {
...selectedRow, ...selectedRow,
...edit, ...edit,
@@ -725,6 +754,7 @@ const ReviewPanel: React.FC = () => {
const exportEpub = async () => { const exportEpub = async () => {
if (!baseUrl) return; if (!baseUrl) return;
setExporting(true);
setExportInfo(''); setExportInfo('');
setMessage(''); setMessage('');
try { try {
@@ -733,9 +763,14 @@ const ReviewPanel: React.FC = () => {
? new URL(payload.download_url, baseUrl).toString() ? new URL(payload.download_url, baseUrl).toString()
: ''; : '';
setExportInfo(downloadUrl || payload.output || ''); setExportInfo(downloadUrl || payload.output || '');
if (downloadUrl) {
await downloadReviewedEpub(downloadUrl, bookState.accessToken);
}
setMessage(`已导出:${payload.output || downloadUrl}`); setMessage(`已导出:${payload.output || downloadUrl}`);
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : String(error)); setMessage(error instanceof Error ? error.message : String(error));
} finally {
setExporting(false);
} }
}; };
@@ -806,7 +841,7 @@ const ReviewPanel: React.FC = () => {
{isMobile && ( {isMobile && (
<Overlay <Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')} className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={() => setPanelVisible(false)} onDismiss={() => void closePanel()}
/> />
)} )}
<aside <aside
@@ -893,7 +928,7 @@ const ReviewPanel: React.FC = () => {
type='button' type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0' className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
title={_('Close')} title={_('Close')}
onClick={() => setPanelVisible(false)} onClick={() => void closePanel()}
> >
<X className='h-4 w-4' /> <X className='h-4 w-4' />
</button> </button>
@@ -901,265 +936,316 @@ const ReviewPanel: React.FC = () => {
<div className='min-h-0 flex-1 overflow-auto p-3'> <div className='min-h-0 flex-1 overflow-auto p-3'>
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='join grid grid-cols-2' role='tablist' aria-label='审校器功能'>
<button
type='button'
role='tab'
aria-selected={panelMode === 'review'}
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'review' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
onClick={() => setPanelMode('review')}
>
</button>
<button
type='button'
role='tab'
aria-selected={panelMode === 'translate'}
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'translate' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
disabled={hasUnsavedEdit}
title={hasUnsavedEdit ? '请先保存当前段落' : '切换到整书翻译'}
onClick={() => setPanelMode('translate')}
>
</button>
</div>
{bookState.loading ? ( {bookState.loading ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'> <div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
EPUB EPUB
</div> </div>
) : null} ) : null}
{bookState.error ? ( {bookState.error ? (
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'> <div
className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'
role='alert'
>
{bookState.error} {bookState.error}
</div> </div>
) : null} ) : null}
<div className='flex flex-wrap gap-2'> {panelMode === 'translate' && baseUrl && sessionId ? (
<PanelButton <div role='tabpanel' aria-label='整书翻译'>
onClick={generateFeedback} <FullBookTranslationPanel
icon={<FileText className='h-4 w-4' />} baseUrl={baseUrl}
disabled={!baseUrl} sessionId={sessionId}
> accessToken={bookState.accessToken}
/>
</PanelButton>
<PanelButton
onClick={exportEpub}
icon={<Download className='h-4 w-4' />}
disabled={!baseUrl}
variant='primary'
>
EPUB
</PanelButton>
</div>
{message ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
{message}
</div> </div>
) : null} ) : null}
{exportInfo ? (
<button
type='button'
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
onClick={() => openExternalUrl(exportInfo)}
>
<span className='font-semibold'></span>
<span className='text-base-content/60 mt-1 block break-all text-xs'>
{exportInfo}
</span>
</button>
) : null}
{!selectedRow ? ( {panelMode === 'review' ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'> <div className='contents' role='tabpanel' aria-label='逐段审校'>
{rows.length <div className='flex flex-wrap gap-2'>
? '请选择一段开始审校。'
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
</div>
) : (
<>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
<span className='min-w-0 break-words'>
{selectedRow.id} · {rowTitle(selectedRow)}
</span>
<span className='shrink-0 break-words'>
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
</span>
</div>
<div
className='prose prose-sm max-w-none select-text leading-8 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
/>
</section>
<section className='grid gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<h3 className='text-sm font-semibold'></h3>
<div className='flex flex-wrap justify-end gap-2'>
<PanelButton
onClick={insertRuby}
disabled={!canUseRowActions}
icon={<Languages className='h-4 w-4' />}
>
</PanelButton>
<PanelButton
onClick={insertAnnotation}
disabled={!canUseRowActions}
icon={<MessageSquarePlus className='h-4 w-4' />}
>
</PanelButton>
<PanelButton
onClick={saveCurrentRow}
disabled={!canUseRowActions || saving}
icon={<Save className='h-4 w-4' />}
variant='primary'
>
{saving ? '保存中' : '保存'}
</PanelButton>
</div>
</div>
<textarea
ref={cnEditorRef}
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
value={edit.current_html}
onChange={(event) =>
setEdit((current) => ({ ...current, current_html: event.target.value }))
}
/>
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<button
type='button'
className='flex w-full items-center justify-between gap-2 text-start'
onClick={() => setPreviewOpen((value) => !value)}
>
<span className='text-base-content/60 text-xs'></span>
<span className='text-base-content/60 text-xs'>
{previewOpen ? '收起' : '展开'}
</span>
</button>
{previewOpen ? (
<div
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
/>
) : null}
</div>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='text-sm font-semibold'></h4>
<p className='text-base-content/60 mt-1 text-xs'>
</p>
</div>
<label className='flex shrink-0 items-center gap-2 text-xs'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={edit.marked}
onChange={(event) =>
setEdit((current) => ({ ...current, marked: event.target.checked }))
}
/>
</label>
</div>
<div className='grid gap-2 sm:grid-cols-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.issue_type}
onChange={(event) =>
setEdit((current) => ({
...current,
issue_type: event.target.value,
}))
}
>
<option value=''></option>
<option value='误译'></option>
<option value='漏译'></option>
<option value='术语不一致'></option>
<option value='人名/地名问题'>/</option>
<option value='翻译腔'></option>
<option value='角色口吻不对'></option>
<option value='语序不顺'></option>
<option value='标点/格式'>/</option>
<option value='其他'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.severity}
onChange={(event) =>
setEdit((current) => ({ ...current, severity: event.target.value }))
}
>
<option value=''></option>
<option value='轻微'></option>
<option value='中等'></option>
<option value='严重'></option>
</select>
</label>
</div>
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={edit.tags}
onChange={(event) =>
setEdit((current) => ({ ...current, tags: event.target.value }))
}
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.comment}
onChange={(event) =>
setEdit((current) => ({ ...current, comment: event.target.value }))
}
placeholder='只记录当前段哪里有问题、为什么这样改'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.learn_note}
onChange={(event) =>
setEdit((current) => ({ ...current, learn_note: event.target.value }))
}
placeholder='只写后续段落也应遵守的规则或口吻偏好'
/>
</label>
</section>
</section>
<section className='border-base-300 grid gap-3 border-t pt-3'>
<h3 className='text-sm font-semibold'>API </h3>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={retranslateInstruction}
onChange={(event) => setRetranslateInstruction(event.target.value)}
placeholder='额外要求,可留空'
/>
<PanelButton <PanelButton
onClick={retranslateCurrentRow} onClick={generateFeedback}
disabled={!canUseRowActions || retranslating} icon={<FileText className='h-4 w-4' />}
icon={<Sparkles className='h-4 w-4' />} disabled={!baseUrl || hasUnsavedEdit}
> >
{retranslating ? '重翻中' : '重翻本段'}
</PanelButton> </PanelButton>
{candidateHtml ? ( <PanelButton
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'> onClick={exportEpub}
<div icon={<Download className='h-4 w-4' />}
className='prose prose-sm max-w-none select-text leading-7 break-words' disabled={!baseUrl || exporting || hasUnsavedEdit}
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }} variant='primary'
/> >
<div className='mt-3'> {exporting ? '导出中' : '导出 EPUB'}
<PanelButton </PanelButton>
onClick={() => </div>
setEdit((current) => ({ ...current, current_html: candidateHtml })) {hasUnsavedEdit ? (
} <p className='text-base-content/70 text-xs'>
icon={<Check className='h-4 w-4' />}
variant='primary' </p>
> ) : null}
</PanelButton>
</div>
</div>
) : null}
</section>
</>
)}
{message ? (
<div
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
role='status'
aria-live='polite'
>
{message}
</div>
) : null}
{exportInfo ? (
<button
type='button'
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
>
<span className='font-semibold'></span>
<span className='text-base-content/60 mt-1 block break-all text-xs'>
{exportInfo}
</span>
</button>
) : null}
{!selectedRow ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
{rows.length
? '请选择一段开始审校。'
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
</div>
) : (
<>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
<span className='min-w-0 break-words'>
{selectedRow.id} · {rowTitle(selectedRow)}
</span>
<span className='shrink-0 break-words'>
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
</span>
</div>
<div
className='prose prose-sm max-w-none select-text leading-8 break-words'
dangerouslySetInnerHTML={{
__html: sanitizeInlineHtml(selectedRow.jp_html),
}}
/>
</section>
<section className='grid gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<h3 className='text-sm font-semibold'></h3>
<div className='flex flex-wrap justify-end gap-2'>
<PanelButton
onClick={insertRuby}
disabled={!canUseRowActions}
icon={<Languages className='h-4 w-4' />}
>
</PanelButton>
<PanelButton
onClick={insertAnnotation}
disabled={!canUseRowActions}
icon={<MessageSquarePlus className='h-4 w-4' />}
>
</PanelButton>
<PanelButton
onClick={saveCurrentRow}
disabled={!canUseRowActions || saving}
icon={<Save className='h-4 w-4' />}
variant='primary'
>
{saving ? '保存中' : '保存'}
</PanelButton>
</div>
</div>
<textarea
ref={cnEditorRef}
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
value={edit.current_html}
onChange={(event) =>
setEdit((current) => ({ ...current, current_html: event.target.value }))
}
/>
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<button
type='button'
className='flex w-full items-center justify-between gap-2 text-start'
onClick={() => setPreviewOpen((value) => !value)}
>
<span className='text-base-content/60 text-xs'></span>
<span className='text-base-content/60 text-xs'>
{previewOpen ? '收起' : '展开'}
</span>
</button>
{previewOpen ? (
<div
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{
__html: sanitizeInlineHtml(edit.current_html),
}}
/>
) : null}
</div>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='text-sm font-semibold'></h4>
<p className='text-base-content/60 mt-1 text-xs'>
</p>
</div>
<label className='flex shrink-0 items-center gap-2 text-xs'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={edit.marked}
onChange={(event) =>
setEdit((current) => ({ ...current, marked: event.target.checked }))
}
/>
</label>
</div>
<div className='grid gap-2 sm:grid-cols-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.issue_type}
onChange={(event) =>
setEdit((current) => ({
...current,
issue_type: event.target.value,
}))
}
>
<option value=''></option>
<option value='误译'></option>
<option value='漏译'></option>
<option value='术语不一致'></option>
<option value='人名/地名问题'>/</option>
<option value='翻译腔'></option>
<option value='角色口吻不对'></option>
<option value='语序不顺'></option>
<option value='标点/格式'>/</option>
<option value='其他'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.severity}
onChange={(event) =>
setEdit((current) => ({ ...current, severity: event.target.value }))
}
>
<option value=''></option>
<option value='轻微'></option>
<option value='中等'></option>
<option value='严重'></option>
</select>
</label>
</div>
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={edit.tags}
onChange={(event) =>
setEdit((current) => ({ ...current, tags: event.target.value }))
}
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.comment}
onChange={(event) =>
setEdit((current) => ({ ...current, comment: event.target.value }))
}
placeholder='只记录当前段哪里有问题、为什么这样改'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.learn_note}
onChange={(event) =>
setEdit((current) => ({ ...current, learn_note: event.target.value }))
}
placeholder='只写后续段落也应遵守的规则或口吻偏好'
/>
</label>
</section>
</section>
<section className='border-base-300 grid gap-3 border-t pt-3'>
<h3 className='text-sm font-semibold'>API </h3>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={retranslateInstruction}
onChange={(event) => setRetranslateInstruction(event.target.value)}
placeholder='额外要求,可留空'
/>
<PanelButton
onClick={retranslateCurrentRow}
disabled={!canUseRowActions || retranslating}
icon={<Sparkles className='h-4 w-4' />}
>
{retranslating ? '重翻中' : '重翻本段'}
</PanelButton>
{candidateHtml ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div
className='prose prose-sm max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
/>
<div className='mt-3'>
<PanelButton
onClick={() =>
setEdit((current) => ({ ...current, current_html: candidateHtml }))
}
icon={<Check className='h-4 w-4' />}
variant='primary'
>
</PanelButton>
</div>
</div>
) : null}
</section>
</>
)}
</div>
) : null}
<GptConfigPanel <GptConfigPanel
baseUrl={baseUrl} baseUrl={baseUrl}
sessionId={sessionId} sessionId={sessionId}
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import type { Book } from '@/types/book'; import type { Book } from '@/types/book';
import type { AppService } from '@/types/system'; import type { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
export type ReviewEditorLaunchResponse = { export type ReviewEditorLaunchResponse = {
ok: boolean; ok: boolean;
@@ -10,6 +10,7 @@ export type ReviewEditorLaunchResponse = {
reused?: boolean; reused?: boolean;
reviewRoot?: string; reviewRoot?: string;
version?: string; version?: string;
accessToken?: string;
sessionId?: string | null; sessionId?: string | null;
error?: string; error?: string;
}; };
@@ -97,6 +98,15 @@ export type ReviewEditPayload = {
learn_note: string; learn_note: string;
}; };
export const isReviewEditDirty = (row: ReviewRow, edit: ReviewEditPayload) =>
edit.current_html !== (row.current_html ?? row.cn_html ?? '') ||
edit.marked !== Boolean(row.marked) ||
edit.issue_type !== (row.issue_type || '') ||
edit.severity !== (row.severity || '') ||
edit.tags !== (row.tags || '') ||
edit.comment !== (row.comment || '') ||
edit.learn_note !== (row.learn_note || '');
export type ReviewSaveRowPayload = { export type ReviewSaveRowPayload = {
status?: string; status?: string;
row_id?: string; row_id?: string;
@@ -110,6 +120,62 @@ export type ReviewGlossaryEntry = {
clean_target?: string; 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: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
source_session_id: string;
source_name?: string;
progress?: {
current?: number;
total?: number;
percent?: number;
failed?: number;
message?: string;
};
output_epub?: string;
output_download_url?: string;
output_session_id?: string;
error?: string;
logs?: { ts?: string; level?: string; message?: string }[];
};
export type TranslationDefaultsPayload = {
gpt?: ReviewGptConfig;
defaults?: {
output_mode?: 'bilingual' | 'translated';
range_mode?: 'all' | 'limit';
limit?: number;
temperature?: number;
chunk_target?: number;
};
};
export type TranslationSourcePayload = {
source_count: number;
files?: { file: string; count: number }[];
stats?: { note?: string };
};
export type ReviewGlossaryPayload = { export type ReviewGlossaryPayload = {
path?: string; path?: string;
exists?: boolean; exists?: boolean;
@@ -134,23 +200,35 @@ const ensureBaseUrl = (baseUrl: string) => {
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}; };
const sidecarAccessTokens = new Map<string, string>();
const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
sidecarAccessTokens.set(ensureBaseUrl(baseUrl), accessToken);
};
export const canLaunchInlineReviewEditor = (
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
localWebDevelopment = process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
export async function sidecarApi<T>( export async function sidecarApi<T>(
baseUrl: string, baseUrl: string,
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
sessionId?: string | null, sessionId?: string | null,
accessToken?: string,
): Promise<T> { ): Promise<T> {
const url = new URL(path, ensureBaseUrl(baseUrl)); const url = new URL(path, ensureBaseUrl(baseUrl));
if (sessionId) { if (sessionId) {
url.searchParams.set('session_id', sessionId); url.searchParams.set('session_id', sessionId);
} }
const headers = const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
options.body === undefined const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
? options.headers const headers = {
: { ...(options.body === undefined || isFormData ? {} : { 'Content-Type': 'application/json' }),
'Content-Type': 'application/json', ...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
...(options.headers || {}), ...(options.headers || {}),
}; };
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
...options, ...options,
headers, headers,
@@ -166,31 +244,62 @@ export async function createReviewSessionFromPath(
baseUrl: string, baseUrl: string,
epubPath: string, epubPath: string,
options: ReviewSessionFromPathOptions = {}, options: ReviewSessionFromPathOptions = {},
accessToken?: string,
): Promise<ReviewSessionSummary> { ): Promise<ReviewSessionSummary> {
return sidecarApi(baseUrl, '/api/session/from-path', { return sidecarApi(
baseUrl,
'/api/session/from-path',
{
method: 'POST',
body: JSON.stringify({
epub_path: epubPath,
activate: options.activate ?? false,
reset: options.reset ?? false,
}),
},
null,
accessToken,
);
}
async function createReviewSessionFromBook(
baseUrl: string,
appService: AppService,
book: Book,
accessToken: string,
): Promise<ReviewSessionSummary> {
const { file } = await appService.loadBookContent(book);
const formData = new FormData();
formData.append('epub', file, file.name || `${book.title || 'book'}.epub`);
formData.append('activate', 'false');
formData.append('book_key', book.hash);
const response = await fetch(new URL('/api/upload', ensureBaseUrl(baseUrl)).toString(), {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: formData,
epub_path: epubPath, headers: { Authorization: `Bearer ${accessToken}` },
activate: options.activate ?? false,
reset: options.reset ?? false,
}),
}); });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as ReviewSessionSummary;
} }
export async function launchInlineReviewEditor( export async function launchInlineReviewEditor(
appService: AppService | null | undefined, appService: AppService | null | undefined,
book: Book, book: Book,
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> { tauriPlatform = isTauriAppPlatform(),
): Promise<
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
> {
if (!appService) throw new Error('Readest 服务尚未初始化'); if (!appService) throw new Error('Readest 服务尚未初始化');
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍'); if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
const epubPath = await appService.resolveNativeBookFilePath(book); const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径'); if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
const launch = isTauriAppPlatform() const launch = tauriPlatform
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { ? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
epubPath,
})
: await fetch('/api/review-editor/launch', { : await fetch('/api/review-editor/launch', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -201,25 +310,30 @@ export async function launchInlineReviewEditor(
if (!launch.ok || !launch.url) { if (!launch.ok || !launch.url) {
throw new Error(launch.error || '审校器启动失败'); throw new Error(launch.error || '审校器启动失败');
} }
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
rememberSidecarAccessToken(launch.url, launch.accessToken);
let sessionId = launch.sessionId || null; let sessionId = launch.sessionId || null;
if (!sessionId) { if (!sessionId) {
const createdSession = await createReviewSessionFromPath(launch.url, epubPath); const createdSession = epubPath
? await createReviewSessionFromPath(launch.url, epubPath, {}, launch.accessToken)
: await createReviewSessionFromBook(launch.url, appService, book, launch.accessToken);
sessionId = createdSession.id || null; sessionId = createdSession.id || null;
} }
return { ...launch, url: launch.url, sessionId }; return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
} }
export async function loadInlineReviewData( export async function loadInlineReviewData(
baseUrl: string, baseUrl: string,
sessionId: string | null | undefined, sessionId: string | null | undefined,
accessToken?: string,
): Promise<ReviewBootstrapPayload> { ): Promise<ReviewBootstrapPayload> {
if (!sessionId) throw new Error('审校器没有返回当前书籍会话'); if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
const [session, rowsPayload, gptConfig] = await Promise.all([ const [session, rowsPayload, gptConfig] = await Promise.all([
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId), sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId), sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId), sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
]); ]);
return { return {
session, session,
@@ -354,3 +468,131 @@ export async function exportReviewedEpub(
sessionId, sessionId,
); );
} }
export async function downloadReviewedEpub(downloadUrl: string, accessToken = ''): Promise<void> {
const url = new URL(downloadUrl);
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(url.origin));
if (!resolvedAccessToken) throw new Error('审校器访问令牌不可用');
const filename = decodeURIComponent(url.pathname.split('/').pop() || 'reviewed.epub');
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${resolvedAccessToken}` },
});
if (!response.ok) throw new Error(`EPUB 下载失败:HTTP ${response.status}`);
const blobUrl = URL.createObjectURL(await response.blob());
const anchor = document.createElement('a');
anchor.href = blobUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(blobUrl);
}
export async function loadTranslationSource(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<TranslationSourcePayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
{},
sessionId,
accessToken,
);
}
export async function loadTranslationDefaults(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<TranslationDefaultsPayload> {
return sidecarApi(baseUrl, '/api/translation/defaults', {}, sessionId, accessToken);
}
export async function loadTranslationPlan(
baseUrl: string,
sessionId: string,
chunkTarget: number,
accessToken?: string,
): Promise<TranslationPlanPayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-plan`,
{ method: 'POST', body: JSON.stringify({ chunk_target: chunkTarget }) },
sessionId,
accessToken,
);
}
export async function startFullBookTranslation(
baseUrl: string,
sessionId: string,
settings: {
output_mode: 'bilingual' | 'translated';
range_mode: 'all' | 'limit';
limit: number;
temperature: number;
chunk_target: number;
},
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
'/api/translation/start',
{ method: 'POST', body: JSON.stringify({ session_id: sessionId, ...settings }) },
sessionId,
accessToken,
);
}
export async function listTranslationJobs(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<{ jobs: TranslationJobSummary[]; count: number }> {
return sidecarApi(baseUrl, '/api/translation/jobs', {}, sessionId, accessToken);
}
export async function loadTranslationJob(
baseUrl: string,
sessionId: string,
jobId: string,
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
{},
sessionId,
accessToken,
);
}
export async function controlTranslationJob(
baseUrl: string,
sessionId: string,
jobId: string,
action: 'pause' | 'resume' | 'cancel',
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`,
{ method: 'POST', body: '{}' },
sessionId,
accessToken,
);
}
export async function probeTranslationModels(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<{
models: string[];
configured_model: string;
configured_model_available: boolean;
}> {
return sidecarApi(baseUrl, '/api/gpt/models', {}, sessionId, accessToken);
}
+23 -6
View File
@@ -12,6 +12,7 @@ export type ReviewBookState = {
loading: boolean; loading: boolean;
error: string; error: string;
baseUrl: string; baseUrl: string;
accessToken: string;
sessionId: string | null; sessionId: string | null;
reviewRoot?: string; reviewRoot?: string;
version?: string; version?: string;
@@ -19,6 +20,7 @@ export type ReviewBookState = {
session: ReviewSessionPayload | null; session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null; gptConfig: ReviewGptConfig | null;
selectedRowId: string; selectedRowId: string;
hasUnsavedEdit: boolean;
}; };
const DEFAULT_PANEL_WIDTH = '32%'; const DEFAULT_PANEL_WIDTH = '32%';
@@ -49,6 +51,7 @@ type ReviewModeStore = {
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>, data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
) => void; ) => void;
selectRow: (bookKey: string, rowId: string) => void; selectRow: (bookKey: string, rowId: string) => void;
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
updateRow: (bookKey: string, row: ReviewRow) => void; updateRow: (bookKey: string, row: ReviewRow) => void;
clearBook: (bookKey: string) => void; clearBook: (bookKey: string) => void;
}; };
@@ -58,11 +61,13 @@ const emptyBookState = (): ReviewBookState => ({
loading: false, loading: false,
error: '', error: '',
baseUrl: '', baseUrl: '',
accessToken: '',
sessionId: null, sessionId: null,
rows: [], rows: [],
session: null, session: null,
gptConfig: null, gptConfig: null,
selectedRowId: '', selectedRowId: '',
hasUnsavedEdit: false,
}); });
const formatSizedValue = (value: number, unit: '%' | 'vh') => const formatSizedValue = (value: number, unit: '%' | 'vh') =>
@@ -153,7 +158,10 @@ export const useReviewModeStore = create<ReviewModeStore>()(
: state.activeBookKey === bookKey : state.activeBookKey === bookKey
? false ? false
: state.isPanelVisible, : state.isPanelVisible,
books: withBookState(state.books, bookKey, { enabled }), books: withBookState(state.books, bookKey, {
enabled,
...(!enabled ? { hasUnsavedEdit: false } : {}),
}),
})), })),
setBookData: (bookKey, data) => setBookData: (bookKey, data) =>
set((state) => ({ set((state) => ({
@@ -164,12 +172,21 @@ export const useReviewModeStore = create<ReviewModeStore>()(
}), }),
})), })),
selectRow: (bookKey, rowId) => selectRow: (bookKey, rowId) =>
set((state) => {
if (state.activeBookKey === bookKey && state.books[bookKey]?.selectedRowId === rowId) {
return state.isPanelVisible ? state : { isPanelVisible: true };
}
return {
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
};
}),
setBookUnsavedEdit: (bookKey, hasUnsavedEdit) =>
set((state) => ({ set((state) => ({
activeBookKey: bookKey, books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
})), })),
updateRow: (bookKey, row) => updateRow: (bookKey, row) =>
set((state) => { set((state) => {
@@ -12,7 +12,7 @@
- `tools/epub-review-editor/static/` 独立网页 - `tools/epub-review-editor/static/` 独立网页
- `open_editor.ps1``epub-reviewer:dev` 独立启动链路 - `open_editor.ps1``epub-reviewer:dev` 独立启动链路
整书翻译、独立审校书架、独立目录/检索页面等功能不属于内嵌审校模式,不应迁入阅读页 整书翻译是阅读页内嵌审校面板的第二个页签,可复用当前书籍 session、GPT 配置和术语表。独立审校书架、独立目录/检索页面、旧 static 网页和 `/review-editor`不属于内嵌模式,不得恢复
## 必须保留的链路 ## 必须保留的链路
@@ -37,6 +37,8 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。 所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机令牌,启动 sidecar 时通过 `--access-token` 传入。所有 API、资源和下载请求(包括 `/api/version` 健康探测)必须携带 `Authorization: Bearer <accessToken>`;响应、日志、反馈和导出文件不得包含该令牌。CORS 预检不要求令牌,但只允许受信任的本机 origin,并显式允许 `Authorization` 请求头。
典型 session 典型 session
```text ```text
@@ -68,6 +70,10 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
- `GET /api/version` - `GET /api/version`
- `POST /api/session/from-path` - `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`
- `/api/session/<session_id>/translation-source``/translation-plan`
- `/api/translation/defaults``/start``/jobs` 及任务控制端点
- `/api/gpt/models``/downloads/translation/...`
- `GET /api/session?session_id=...` - `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...` - `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...` - `POST /api/row/<row_id>?session_id=...`
@@ -80,7 +86,9 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。 中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限 CORS;不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。 sidecar 只能监听本机 loopbackCLI 必须拒绝 `0.0.0.0``::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。loopback 与 CORS 不是请求鉴权的替代品,任何有副作用的端点都不得绕过 Bearer 令牌。
桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
## 前端验收标准 ## 前端验收标准
@@ -105,7 +113,7 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
- MINOR:向后兼容的能力或 API 扩展 - MINOR:向后兼容的能力或 API 扩展
- MAJOR:删除入口、破坏 API 或改变持久化行为 - MAJOR:删除入口、破坏 API 或改变持久化行为
发版时同步 `version.py``README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。 发版时同步 `version.py``README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。`2.1.0` 在当前安全契约上新增内嵌整书翻译、计划、任务控制、断点续跑和安全输出下载,翻译状态为 `untranslated``translating``translated``failed`
## 回归清单 ## 回归清单
@@ -114,8 +122,9 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
```powershell ```powershell
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
python tools/epub-review-editor/test_inline_review_session_scope.py python tools/epub-review-editor/test_inline_review_session_scope.py
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts src/__tests__/components/ReviewModeToggler.test.tsx
pnpm exec tsgo --noEmit --pretty false pnpm exec tsgo --noEmit --pretty false
$env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
``` ```
涉及 Tauri 配置或 Rust 启动器时继续执行: 涉及 Tauri 配置或 Rust 启动器时继续执行:
@@ -1,6 +1,6 @@
# Readest 内嵌 EPUB 审校后端 # Readest 内嵌 EPUB 审校后端
当前版本:`1.0.0` 当前版本:`2.1.0`
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。 本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
@@ -15,6 +15,8 @@
`server.py``version.py``requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。 `server.py``version.py``requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。
阅读页右侧面板提供“逐段审校 / 整书翻译”两个页签。整书翻译支持保持 XHTML 文件边界的计划预览、试译或全书任务、断点、暂停、恢复、取消和带令牌的输出下载;旧独立网页、独立书架和 `/review-editor` 不会恢复。
## 使用方式 ## 使用方式
桌面端: 桌面端:
@@ -29,7 +31,11 @@ Web 开发端:
pnpm --filter @readest/readest-app dev-web pnpm --filter @readest/readest-app dev-web
``` ```
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动。两者都会注册当前 EPUB 的 session,前端后续请求必须携带该 `session_id` 在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。启动器会在 review root 生成仅供本机使用的随机访问令牌;前端后续请求必须同时携带 `Authorization: Bearer <accessToken>` 和当前书的 `session_id`
该 Web 链路仅支持本机 `dev-web`,依赖本机 Next.js Node 进程和 Python sidecar。部署到 Cloudflare 等托管环境的 Readest Web 不支持当前内嵌审校器。
本地端到端回归可设置 `READEST_REVIEW_E2E=1` 后运行 `pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium`。测试会即时生成不含版权内容的最小双语 EPUB,并验证开启、保存、重开续审和导出下载。
## 审校能力 ## 审校能力
@@ -64,10 +70,22 @@ pnpm --filter @readest/readest-app dev-web
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。 API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload``/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
## 主要 API ## 主要 API
- `GET /api/version` - `GET /api/version`
- `POST /api/session/from-path` - `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web`multipart`activate=false`,携带稳定 `book_key` 复用会话)
- `GET /api/session/<session_id>/translation-source?session_id=...`
- `POST /api/session/<session_id>/translation-plan?session_id=...`
- `GET /api/translation/defaults?session_id=...`
- `POST /api/translation/start?session_id=...`
- `GET /api/translation/jobs?session_id=...`
- `GET|POST /api/translation/jobs/<job_id>[/<action>]?session_id=...`
- `GET /api/gpt/models?session_id=...`
翻译状态为 `untranslated``translating``translated``failed`。输出下载必须继续携带 Bearer 令牌和源书 `session_id`,公开响应不返回 API Key。
- `GET /api/session?session_id=...` - `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...` - `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...` - `POST /api/row/<row_id>?session_id=...`
@@ -84,3 +102,6 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。 - `0.16.0`:新增 Readest 阅读页内嵌审校模式。
- `0.16.1``0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。 - `0.16.1``0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。 - `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
- `2.1.0`:在 Readest 阅读页内嵌整书翻译页签,新增翻译计划、任务列表、断点续跑、暂停/恢复/取消、模型探测和安全输出下载;默认开发基座改为本地 `dev-web`,保留 Tauri 兼容。
@@ -4,6 +4,7 @@ import argparse
import copy import copy
import datetime as dt import datetime as dt
import hashlib import hashlib
import hmac
import html import html
import json import json
import os import os
@@ -35,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置", "MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更", "MAJOR": "破坏兼容的 API、配置或行为变更",
} }
version = "1.0.0" version = "2.0.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I) P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -103,6 +104,8 @@ TRANSLATION_LOG_LIMIT = 80
DEFAULT_TRANSLATION_LIMIT = 20 DEFAULT_TRANSLATION_LIMIT = 20
TRANSLATION_RANGE_LIMIT_MAX = 5000 TRANSLATION_RANGE_LIMIT_MAX = 5000
TRANSLATION_CONTEXT_RADIUS = 1 TRANSLATION_CONTEXT_RADIUS = 1
DEFAULT_TRANSLATION_CHUNK_TARGET = 24
TRANSLATION_CHUNK_TARGET_MAX = 120
LIBRARY_DB_NAME = "library.json" LIBRARY_DB_NAME = "library.json"
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity" ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
@@ -139,7 +142,21 @@ def find_free_port(start: int, host: str = "127.0.0.1") -> int:
def display_host(host: str) -> str: def display_host(host: str) -> str:
return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host return "localhost" if host in {"127.0.0.1", "::1"} else host
def is_loopback_host(host: str) -> bool:
return host.strip().lower() in {"127.0.0.1", "localhost", "::1"}
def is_allowed_local_origin(origin: str) -> bool:
try:
parsed = urllib.parse.urlparse(origin)
return parsed.scheme in {"http", "https", "tauri"} and is_loopback_host(
parsed.hostname or ""
)
except ValueError:
return False
def read_json(path: Path, default: Any) -> Any: def read_json(path: Path, default: Any) -> Any:
@@ -1121,6 +1138,32 @@ def call_openai_compatible_chat(
return content 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: def session_root_for(review_root: Path, epub_path: Path) -> Path:
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10] digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
return review_root / f"{safe_slug(epub_path.stem)}_{digest}" return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
@@ -1146,6 +1189,10 @@ def library_db_path(review_root: Path) -> Path:
return review_root / LIBRARY_DB_NAME 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: 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" return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
@@ -1288,7 +1335,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
updated_at = state.get("updated_at") or manifest.get("updated_at") or "" updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
session_id = session_public_id(session_root) session_id = session_public_id(session_root)
cover_asset_path = str(metadata.get("cover_asset_path") or "") cover_asset_path = str(metadata.get("cover_asset_path") or "")
return { summary = {
"id": session_id, "id": session_id,
"version": version, "version": version,
"source_name": source_name, "source_name": source_name,
@@ -1315,6 +1362,13 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
"latest_export": latest_export, "latest_export": latest_export,
"reading_position": normalize_reading_position(state.get("reading_position", {})), "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: def rowTouched_py(row: dict[str, Any]) -> bool:
@@ -1350,6 +1404,25 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
def build_library_index(review_root: Path) -> dict[str, Any]: def build_library_index(review_root: Path) -> dict[str, Any]:
sessions = list_review_sessions(review_root) 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 ""), [])
latest = session_jobs[0] if session_jobs else None
latest_status = str(latest.get("status") or "") if latest else ""
session["translation_status"] = (
"translated"
if latest_status == "completed"
else "translating"
if latest_status in {"pending", "running", "paused"}
else "failed"
if latest_status in {"failed", "cancelled"}
else "untranslated"
)
session["translation_job"] = latest
series_map: dict[str, dict[str, Any]] = {} series_map: dict[str, dict[str, Any]] = {}
for session in sessions: for session in sessions:
series_id = str(session.get("series_id") or "uncategorized") series_id = str(session.get("series_id") or "uncategorized")
@@ -1374,6 +1447,22 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
series["publishers"].add(session["publisher"]) series["publishers"].add(session["publisher"])
if session.get("language"): if session.get("language"):
series["languages"].add(session["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 = [] series_list = []
for item in series_map.values(): for item in series_map.values():
series_list.append( series_list.append(
@@ -1397,6 +1486,44 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
return library 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]: def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
path = series_config_path(review_root, series_id) path = series_config_path(review_root, series_id)
data = read_json(path, {}) data = read_json(path, {})
@@ -1444,14 +1571,20 @@ def validate_epub_file(path: Path) -> None:
raise ValueError("文件不是有效的 EPUB/ZIP 包") raise ValueError("文件不是有效的 EPUB/ZIP 包")
def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path: def copy_upload_to_review_root(
file_storage: Any, review_root: Path, book_key: str = ""
) -> Path:
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140) filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
if not filename.lower().endswith(".epub"): if not filename.lower().endswith(".epub"):
filename = f"{filename}.epub" filename = f"{filename}.epub"
upload_dir = review_root / UPLOAD_DIR_NAME upload_dir = review_root / UPLOAD_DIR_NAME
upload_dir.mkdir(parents=True, exist_ok=True) upload_dir.mkdir(parents=True, exist_ok=True)
stem = safe_slug(Path(filename).stem, max_len=100) stem = safe_slug(Path(filename).stem, max_len=100)
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub" if book_key:
safe_book_key = safe_slug(book_key, max_len=120)
target = upload_dir / f"inline_{safe_book_key}.epub"
else:
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{stem}.epub"
try: try:
file_storage.save(target) file_storage.save(target)
validate_epub_file(target) validate_epub_file(target)
@@ -2636,9 +2769,11 @@ def write_feedback_summary(session_root: Path) -> Path:
def apply_edits_to_xhtml(session_root: Path) -> list[str]: def apply_edits_to_xhtml(session_root: Path) -> list[str]:
rows = merge_rows(session_root) rows = merge_rows(session_root)
edited_rows = [row for row in rows if row["edited"]] state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
saved_row_ids = set(state.get("edits", {})) if isinstance(state.get("edits"), dict) else set()
saved_rows = [row for row in rows if row["id"] in saved_row_ids]
by_file: dict[str, list[dict[str, Any]]] = {} by_file: dict[str, list[dict[str, Any]]] = {}
for row in edited_rows: for row in saved_rows:
by_file.setdefault(row["file"], []).append(row) by_file.setdefault(row["file"], []).append(row)
modified = [] modified = []
for entry_name, items in by_file.items(): for entry_name, items in by_file.items():
@@ -2745,6 +2880,14 @@ def add_translation_job_log(job: dict[str, Any], message: str, level: str = "inf
def public_translation_job(job: dict[str, Any]) -> dict[str, Any]: def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
settings = job.get("settings") if isinstance(job.get("settings"), dict) else {} settings = job.get("settings") if isinstance(job.get("settings"), dict) else {}
output_epub = Path(str(job.get("output_epub") or ""))
output_download_url = ""
if output_epub.name and job.get("status") == "completed":
output_download_url = (
f"/downloads/translation/{urllib.parse.quote(str(job.get('id') or ''))}/"
f"{urllib.parse.quote(output_epub.name)}"
f"?session_id={urllib.parse.quote(str(job.get('source_session_id') or ''))}"
)
return { return {
"id": job.get("id", ""), "id": job.get("id", ""),
"status": job.get("status", ""), "status": job.get("status", ""),
@@ -2758,6 +2901,7 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
"error": job.get("error", ""), "error": job.get("error", ""),
"logs": job.get("logs", []), "logs": job.get("logs", []),
"output_epub": job.get("output_epub", ""), "output_epub": job.get("output_epub", ""),
"output_download_url": output_download_url,
"output_session_id": job.get("output_session_id", ""), "output_session_id": job.get("output_session_id", ""),
"settings_summary": { "settings_summary": {
"output_mode": settings.get("output_mode", "bilingual"), "output_mode": settings.get("output_mode", "bilingual"),
@@ -2765,10 +2909,29 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT), "limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
"glossary_path": settings.get("glossary_path", ""), "glossary_path": settings.get("glossary_path", ""),
"temperature": settings.get("temperature", 0.2), "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]: 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() output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
if output_mode not in {"bilingual", "translated"}: if output_mode not in {"bilingual", "translated"}:
@@ -2786,6 +2949,11 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValueError("temperature 必须是数字") from exc raise ValueError("temperature 必须是数字") from exc
temperature = max(0.0, min(1.0, temperature)) 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: def prompt_value(name: str) -> Any:
if data.get(f"use_series_{name}"): if data.get(f"use_series_{name}"):
return None return None
@@ -2797,6 +2965,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
"range_mode": range_mode, "range_mode": range_mode,
"limit": limit, "limit": limit,
"temperature": temperature, "temperature": temperature,
"chunk_target": chunk_target,
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "", "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")), "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")), "format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
@@ -2804,6 +2973,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: def html_looks_like_navigation(entry_name: str, text: str) -> bool:
basename = posixpath.basename(entry_name).lower() basename = posixpath.basename(entry_name).lower()
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename: if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
@@ -3120,6 +3327,22 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
if not selected_items: if not selected_items:
raise ValueError("没有找到可翻译的日文正文块") 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( job["progress"].update(
{ {
"total": len(selected_items), "total": len(selected_items),
@@ -3133,6 +3356,41 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
failed_count = 0 failed_count = 0
errors: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = []
for index, item in enumerate(selected_items): 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
if control == "pause":
job["status"] = "paused"
job["progress"]["message"] = "任务已暂停,断点已保留。"
add_translation_job_log(job, job["progress"]["message"])
write_translation_job(review_root, job)
return
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( job["progress"].update(
{ {
"current": index, "current": index,
@@ -3161,13 +3419,11 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
failed_count += 1 failed_count += 1
error_text = str(exc)[:900] error_text = str(exc)[:900]
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text}) 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") 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(
write_json_atomic( checkpoint_path,
translation_job_root(review_root, job_id) / "translations.json", {"items": selected_items, "translations": translations, "errors": errors},
{"items": selected_items, "translations": translations, "errors": errors}, )
)
job["progress"].update( job["progress"].update(
{ {
@@ -3227,7 +3483,14 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
write_translation_job(review_root, job) write_translation_job(review_root, job)
def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask: def create_app(
review_root: Path,
initial_epub_path: Path | None = None,
reset: bool = False,
access_token: str = "",
) -> Flask:
if not access_token:
raise ValueError("Readest inline review API requires an access token.")
app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None) app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None)
review_root.mkdir(parents=True, exist_ok=True) review_root.mkdir(parents=True, exist_ok=True)
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True) translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
@@ -3237,6 +3500,53 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
app.config["EPUB_PATH"] = None app.config["EPUB_PATH"] = None
app.config["LOCK"] = threading.Lock() app.config["LOCK"] = threading.Lock()
app.config["TRANSLATION_THREADS"] = {} app.config["TRANSLATION_THREADS"] = {}
app.config["ACCESS_TOKEN"] = access_token
def requires_explicit_session_id(path: str) -> bool:
exact_paths = {
"/api/session",
"/api/rows",
"/api/structure",
"/api/reading-position",
"/api/gpt/config",
"/api/gpt/models",
"/api/glossary",
"/api/glossary/entry",
"/api/glossary/search",
"/api/apply",
"/api/export",
"/api/feedback",
"/api/translation/defaults",
"/api/translation/start",
"/api/translation/jobs",
}
return (
path in exact_paths
or path.startswith("/api/row/")
or (path.startswith("/api/session/") and "/translation-" in path)
or path.startswith("/api/translation/jobs/")
or path.startswith("/api/asset/")
or path.startswith("/downloads/")
)
@app.before_request
def authorize_local_client():
if not (request.path.startswith("/api/") or request.path.startswith("/downloads/")):
return None
if request.method == "OPTIONS":
return None
authorization = request.headers.get("Authorization", "")
expected = f"Bearer {app.config['ACCESS_TOKEN']}"
if not hmac.compare_digest(authorization, expected):
response = jsonify({"error": "Unauthorized sidecar request"})
response.status_code = 401
response.headers["WWW-Authenticate"] = "Bearer"
return response
if requires_explicit_session_id(request.path):
session_id = str(request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "缺少 session_id"}), 400
return None
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None: def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
app.config["SESSION_ROOT"] = session_root app.config["SESSION_ROOT"] = session_root
@@ -3267,7 +3577,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def session_from_request() -> tuple[Path | None, Path | None]: def session_from_request() -> tuple[Path | None, Path | None]:
session_id = str(request.args.get("session_id") or "").strip() session_id = str(request.args.get("session_id") or "").strip()
if not session_id: if not session_id:
return active_session() return None, None
try: try:
session_root = session_root_from_id(review_root, session_id) session_root = session_root_from_id(review_root, session_id)
except ValueError: except ValueError:
@@ -3296,20 +3606,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def add_local_embedding_headers(response): def add_local_embedding_headers(response):
# The Readest clients call the loopback API directly. Keep CORS limited # The Readest clients call the loopback API directly. Keep CORS limited
# to known local app origins instead of opening the sidecar to the web. # to known local app origins instead of opening the sidecar to the web.
allowed_origins = {
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://tauri.localhost",
"https://tauri.localhost",
"tauri://localhost",
}
origin = request.headers.get("Origin", "") origin = request.headers.get("Origin", "")
if origin in allowed_origins: if is_allowed_local_origin(origin):
response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "false" response.headers["Access-Control-Allow-Credentials"] = "false"
response.headers["Access-Control-Allow-Headers"] = "Content-Type" response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS" response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
response.headers["Vary"] = "Origin" response.headers["Vary"] = "Origin"
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp") response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
@@ -3380,6 +3681,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def api_library(): def api_library():
return jsonify(build_library_index(review_root)) 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/<series_id>/config", methods=["GET", "POST"]) @app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
def api_series_config(series_id: str): def api_series_config(series_id: str):
series_id = safe_slug(series_id, max_len=120) series_id = safe_slug(series_id, max_len=120)
@@ -3397,15 +3706,23 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if file_storage is None or not file_storage.filename: if file_storage is None or not file_storage.filename:
return jsonify({"error": "请选择一个 .epub 文件"}), 400 return jsonify({"error": "请选择一个 .epub 文件"}), 400
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"} reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
book_key = str(request.form.get("book_key", "")).strip()
activate_upload = str(request.form.get("activate", "true")).lower() in {
"1",
"true",
"yes",
"on",
}
try: try:
epub_path = copy_upload_to_review_root(file_storage, review_root) epub_path = copy_upload_to_review_root(file_storage, review_root, book_key)
session_root = session_root_for(review_root, epub_path) session_root = session_root_for(review_root, epub_path)
with app.config["LOCK"]: with app.config["LOCK"]:
if reset_upload and session_root.exists(): if reset_upload and session_root.exists():
shutil.rmtree(session_root) shutil.rmtree(session_root)
ensure_state(session_root, epub_path, reset=reset_upload) ensure_state(session_root, epub_path, reset=reset_upload)
write_session_manifest(session_root, epub_path, source_name=file_storage.filename) write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
set_active_session(session_root, epub_path) if activate_upload:
set_active_session(session_root, epub_path)
summary = summarize_session(session_root) summary = summarize_session(session_root)
summary["has_session"] = True summary["has_session"] = True
return jsonify(summary) return jsonify(summary)
@@ -3586,6 +3903,25 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 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"]) @app.route("/api/glossary", methods=["GET", "POST"])
def api_glossary(): def api_glossary():
session_root, _epub_path = session_from_request() session_root, _epub_path = session_from_request()
@@ -3658,7 +3994,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/translation/defaults") @app.route("/api/translation/defaults")
def api_translation_defaults(): def api_translation_defaults():
config = public_gpt_config(review_root) session_root, _epub_path = session_from_request()
config = public_scoped_gpt_config(review_root, session_root)
return jsonify( return jsonify(
{ {
"version": version, "version": version,
@@ -3668,6 +4005,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"range_mode": "limit", "range_mode": "limit",
"limit": DEFAULT_TRANSLATION_LIMIT, "limit": DEFAULT_TRANSLATION_LIMIT,
"temperature": 0.2, "temperature": 0.2,
"chunk_target": DEFAULT_TRANSLATION_CHUNK_TARGET,
"translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT, "translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT,
"format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT, "format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT,
"character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT, "character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT,
@@ -3722,12 +4060,42 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
except Exception as exc: except Exception as exc:
return jsonify({"error": f"读取翻译源失败:{exc}"}), 500 return jsonify({"error": f"读取翻译源失败:{exc}"}), 500
@app.route("/api/session/<session_id>/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"]) @app.route("/api/translation/start", methods=["POST"])
def api_translation_start(): def api_translation_start():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
session_id = str(data.get("session_id") or "").strip() session_id = str(request.args.get("session_id") or "").strip()
if not session_id: if not session_id:
return jsonify({"error": "缺少 session_id"}), 400 return jsonify({"error": "缺少 session_id"}), 400
body_session_id = str(data.get("session_id") or "").strip()
if body_session_id and body_session_id != session_id:
return jsonify({"error": "请求中的 session_id 不一致"}), 400
try: try:
source_root = session_root_from_id(review_root, session_id) source_root = session_root_from_id(review_root, session_id)
except ValueError as exc: except ValueError as exc:
@@ -3739,7 +4107,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
try: try:
requested_base_url = str(data.get("base_url") or "").strip() requested_base_url = str(data.get("base_url") or "").strip()
requested_model = str(data.get("model") or "").strip() requested_model = str(data.get("model") or "").strip()
config = read_gpt_config(review_root) config = read_scoped_gpt_config(review_root, source_root)
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]: if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400 return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400
if requested_model and requested_model != config["model"]: if requested_model and requested_model != config["model"]:
@@ -3790,6 +4158,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
except Exception as exc: except Exception as exc:
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500 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/<job_id>") @app.route("/api/translation/jobs/<job_id>")
def api_translation_job(job_id: str): def api_translation_job(job_id: str):
try: try:
@@ -3798,6 +4174,50 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if not job: if not job:
return jsonify({"error": "翻译任务不存在"}), 404 return jsonify({"error": "翻译任务不存在"}), 404
if job.get("source_session_id") != request.args.get("session_id"):
return jsonify({"error": "翻译任务不属于当前书籍会话"}), 404
return jsonify({"job": public_translation_job(job)})
@app.route("/api/translation/jobs/<job_id>/<action>", 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
if job.get("source_session_id") != request.args.get("session_id"):
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
running_thread = app.config["TRANSLATION_THREADS"].get(job_id)
if running_thread and running_thread.is_alive():
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)}) return jsonify({"job": public_translation_job(job)})
@app.route("/api/row/<row_id>/retranslate", methods=["POST"]) @app.route("/api/row/<row_id>/retranslate", methods=["POST"])
@@ -3977,6 +4397,24 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return no_active_response() return no_active_response()
return send_from_directory(session_root / "exports", filename, as_attachment=True) return send_from_directory(session_root / "exports", filename, as_attachment=True)
@app.route("/downloads/translation/<job_id>/<path:filename>")
def download_translation(job_id: str, filename: str):
try:
job = read_translation_job(review_root, job_id)
except ValueError:
return jsonify({"error": "翻译任务不存在"}), 404
if not job or job.get("source_session_id") != request.args.get("session_id"):
return jsonify({"error": "翻译任务不存在"}), 404
output_path = Path(str(job.get("output_epub") or ""))
output_root = (review_root / TRANSLATION_OUTPUT_DIR_NAME).resolve()
try:
output_path.resolve().relative_to(output_root)
except ValueError:
return jsonify({"error": "翻译输出路径无效"}), 400
if filename != output_path.name or not output_path.is_file():
return jsonify({"error": "翻译输出不存在"}), 404
return send_from_directory(output_path.parent, output_path.name, as_attachment=True)
@app.route("/downloads/feedback/<path:filename>") @app.route("/downloads/feedback/<path:filename>")
def download_feedback(filename: str): def download_feedback(filename: str):
session_root, _epub_path = session_from_request() session_root, _epub_path = session_from_request()
@@ -3998,13 +4436,17 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return app return app
def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool: def wait_until_ready(url: str, proc: subprocess.Popen, access_token: str, timeout: int = 15) -> bool:
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
if proc.poll() is not None: if proc.poll() is not None:
return False return False
try: try:
with urllib.request.urlopen(f"{url}/api/session", timeout=1) as response: version_request = urllib.request.Request(
f"{url}/api/version",
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(version_request, timeout=1) as response:
if response.status == 200: if response.status == 200:
return True return True
except OSError: except OSError:
@@ -4043,6 +4485,8 @@ def run_daemon(args: argparse.Namespace) -> int:
args.host, args.host,
"--port", "--port",
str(port), str(port),
"--access-token",
args.access_token,
] ]
if epub_path is not None: if epub_path is not None:
cmd.insert(2, str(epub_path)) cmd.insert(2, str(epub_path))
@@ -4051,7 +4495,11 @@ def run_daemon(args: argparse.Namespace) -> int:
creationflags = 0 creationflags = 0
popen_kwargs: dict[str, Any] = {} popen_kwargs: dict[str, Any] = {}
if os.name == "nt": if os.name == "nt":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS creationflags = (
subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
| subprocess.CREATE_NO_WINDOW
)
else: else:
popen_kwargs["start_new_session"] = True popen_kwargs["start_new_session"] = True
with log_path.open("a", encoding="utf-8") as log: with log_path.open("a", encoding="utf-8") as log:
@@ -4064,7 +4512,7 @@ def run_daemon(args: argparse.Namespace) -> int:
**popen_kwargs, **popen_kwargs,
) )
url = f"http://{display_host(args.host)}:{port}" url = f"http://{display_host(args.host)}:{port}"
if not wait_until_ready(url, proc): if not wait_until_ready(url, proc, args.access_token):
print(f"review editor failed to start; see {log_path}", file=sys.stderr) print(f"review editor failed to start; see {log_path}", file=sys.stderr)
return 1 return 1
print(url) print(url)
@@ -4080,8 +4528,9 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the local Readest inline review API.") parser = argparse.ArgumentParser(description="Run the local Readest inline review API.")
parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.") parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.")
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.") parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.") parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind.")
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.") parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
parser.add_argument("--access-token", required=True, help=argparse.SUPPRESS)
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.") parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.") parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
return parser return parser
@@ -4089,6 +4538,9 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
if not is_loopback_host(args.host):
print("Readest inline review API only supports loopback hosts.", file=sys.stderr)
return 1
epub_path = Path(args.epub).resolve() if args.epub else None epub_path = Path(args.epub).resolve() if args.epub else None
if epub_path is not None: if epub_path is not None:
if not epub_path.exists(): if not epub_path.exists():
@@ -4103,7 +4555,7 @@ def main(argv: list[str] | None = None) -> int:
return run_daemon(args) return run_daemon(args)
review_root = Path(args.review_root).resolve() review_root = Path(args.review_root).resolve()
app = create_app(review_root, epub_path, reset=args.reset) app = create_app(review_root, epub_path, reset=args.reset, access_token=args.access_token)
port = find_free_port(args.port, args.host) port = find_free_port(args.port, args.host)
url = f"http://{display_host(args.host)}:{port}" url = f"http://{display_host(args.host)}:{port}"
def on_sigterm(_signum: int, _frame: Any) -> None: def on_sigterm(_signum: int, _frame: Any) -> None:
@@ -0,0 +1,189 @@
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_keeps_existing_compatibility(self):
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-4o-mini")
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_failed_latest_job_is_not_reported_as_translating(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_failed",
"status": "failed",
"source_session_id": session.name,
"settings": {},
"logs": [],
},
)
library = server.build_library_index(root)
self.assertEqual(library["sessions"][0]["translation_status"], "failed")
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:
token = "test-sidecar-token"
app = server.create_app(Path(temp_dir), access_token=token)
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?session_id=test-session",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertEqual(body["models"], ["gpt-5.6-sol"])
self.assertNotIn("secret-value", str(body))
def test_translation_routes_require_token_and_explicit_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
token = "test-sidecar-token"
app = server.create_app(Path(temp_dir), access_token=token)
client = app.test_client()
missing_token = client.get("/api/translation/jobs?session_id=book")
missing_session = client.get(
"/api/translation/jobs",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(missing_token.status_code, 401)
self.assertEqual(missing_session.status_code, 400)
def test_translation_job_control_rejects_another_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
token = "test-sidecar-token"
server.write_translation_job(
root,
{
"id": "tr_owned",
"status": "paused",
"source_session_id": "book-a",
"settings": {},
"logs": [],
},
)
app = server.create_app(root, access_token=token)
response = app.test_client().post(
"/api/translation/jobs/tr_owned/resume?session_id=book-b",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status_code, 404)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,5 @@
import importlib.util import importlib.util
import io
import json import json
import tempfile import tempfile
import unittest import unittest
@@ -18,9 +19,77 @@ def write_json(path: Path, data):
class InlineReviewSessionScopeTest(unittest.TestCase): class InlineReviewSessionScopeTest(unittest.TestCase):
ACCESS_TOKEN = "test-sidecar-token"
def make_app(self, review_root: Path, **kwargs):
return server.create_app(review_root, access_token=self.ACCESS_TOKEN, **kwargs)
def client(self, app):
client = app.test_client()
client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {self.ACCESS_TOKEN}"
return client
def test_cli_rejects_non_loopback_host(self):
self.assertNotEqual(
server.main(["--host", "0.0.0.0", "--access-token", self.ACCESS_TOKEN]), 0
)
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = self.client(app).get(
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://127.0.0.1:3017",
)
self.assertIn(
"Authorization", response.headers.get("Access-Control-Allow-Headers", "")
)
def test_sidecar_rejects_missing_or_incorrect_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
client = app.test_client()
missing = client.get("/api/version")
incorrect = client.get(
"/api/version", headers={"Authorization": "Bearer incorrect"}
)
authorized = client.get(
"/api/version",
headers={"Authorization": f"Bearer {self.ACCESS_TOKEN}"},
)
self.assertEqual(missing.status_code, 401)
self.assertEqual(incorrect.status_code, 401)
self.assertEqual(authorized.status_code, 200)
self.assertNotIn(self.ACCESS_TOKEN, authorized.get_data(as_text=True))
def test_cors_preflight_does_not_require_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = app.test_client().options(
"/api/rows?session_id=session-a",
headers={
"Origin": "http://localhost:3017",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://localhost:3017",
)
def test_sidecar_has_no_legacy_standalone_page(self): def test_sidecar_has_no_legacy_standalone_page(self):
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
app = server.create_app(Path(temp_dir)) app = self.make_app(Path(temp_dir))
response = app.test_client().get("/") response = app.test_client().get("/")
@@ -39,6 +108,121 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
'<span class="review-annotation">(注:注释)</span>', '<span class="review-annotation">(注:注释)</span>',
) )
def test_saved_row_restores_initial_html_after_previous_apply(self):
with tempfile.TemporaryDirectory() as temp_dir:
session_root = Path(temp_dir)
review_state = session_root / "review_state"
extracted = session_root / "extracted" / "Text"
review_state.mkdir(parents=True)
extracted.mkdir(parents=True)
row = {
"id": "R00001",
"file": "Text/chapter.xhtml",
"file_label": "chapter.xhtml",
"ja_p_index": 0,
"cn_p_index": 1,
"jp_html": "原文",
"jp_text": "原文",
"cn_html": "初始译文",
"cn_text": "初始译文",
}
write_json(review_state / "rows.json", [row])
write_json(
server.rows_meta_path(session_root),
{"parser_version": server.ROWS_PARSER_VERSION, "row_count": 1},
)
write_json(
review_state / "state.json",
{"edits": {"R00001": {"current_html": "初始译文"}}},
)
xhtml_path = extracted / "chapter.xhtml"
xhtml_path.write_text(
'<html><body><p class="sourceText">原文</p><p>先前修改</p></body></html>',
encoding="utf-8",
)
modified = server.apply_edits_to_xhtml(session_root)
self.assertEqual(modified, ["Text/chapter.xhtml"])
self.assertIn("<p>初始译文</p>", xhtml_path.read_text(encoding="utf-8"))
self.assertEqual(
json.loads((review_state / "rows.json").read_text(encoding="utf-8")),
[row],
)
def test_upload_can_create_a_session_without_changing_the_active_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
first_epub = review_root / "first.epub"
self.write_minimal_epub(first_epub)
first_session = self.make_session(
review_root, "first", first_epub, review_root / "glossary.json"
)
app = self.make_app(review_root=review_root, initial_epub_path=first_epub)
client = self.client(app)
response = client.post(
"/api/upload",
data={
"epub": (io.BytesIO(first_epub.read_bytes()), "uploaded.epub"),
"activate": "false",
},
content_type="multipart/form-data",
)
self.assertEqual(response.status_code, 200)
uploaded_id = response.get_json()["id"]
active_session_id = client.get("/api/sessions").get_json()["active_session_id"]
self.assertNotEqual(active_session_id, uploaded_id)
self.assertIn("first", active_session_id)
scoped_session = client.get(f"/api/session?session_id={uploaded_id}")
self.assertEqual(scoped_session.status_code, 200)
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
for endpoint in (
"/api/session",
"/api/rows",
"/api/gpt/config",
"/api/glossary",
"/api/feedback",
):
unscoped = client.get(endpoint)
self.assertEqual(unscoped.status_code, 400, endpoint)
self.assertIn("session_id", unscoped.get_data(as_text=True))
def test_inline_upload_reuses_the_book_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
epub = review_root / "book.epub"
self.write_minimal_epub(epub)
app = self.make_app(review_root=review_root)
client = self.client(app)
def upload(filename: str):
return client.post(
"/api/upload",
data={
"epub": (io.BytesIO(epub.read_bytes()), filename),
"activate": "false",
"book_key": "stable-book-hash",
},
content_type="multipart/form-data",
)
first = upload("book.epub")
second = upload("renamed-book.epub")
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.get_json()["id"], second.get_json()["id"])
@staticmethod
def write_minimal_epub(path: Path) -> None:
import zipfile
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("mimetype", "application/epub+zip")
def make_session( def make_session(
self, self,
review_root: Path, review_root: Path,
@@ -127,8 +311,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
server.call_openai_compatible_chat = fake_call server.call_openai_compatible_chat = fake_call
try: try:
app = server.create_app(review_root) app = self.make_app(review_root)
response = app.test_client().post( response = self.client(app).post(
"/api/row/R00001/retranslate?session_id=session-a", "/api/row/R00001/retranslate?session_id=session-a",
json={"instruction": ""}, json={"instruction": ""},
) )
@@ -157,8 +341,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
epub_path.write_bytes(b"not a real epub") epub_path.write_bytes(b"not a real epub")
self.make_session(review_root, "session-a", epub_path, glossary) self.make_session(review_root, "session-a", epub_path, glossary)
app = server.create_app(review_root) app = self.make_app(review_root)
response = app.test_client().post( response = self.client(app).post(
"/api/gpt/config?session_id=session-a", "/api/gpt/config?session_id=session-a",
json={ json={
"api_key": "session-secret", "api_key": "session-secret",
@@ -1,4 +1,4 @@
version = "1.0.0" version = "2.1.0"
VERSION_RULES = { VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修", "PATCH": "修复 bug 或兼容性小修",
+112 -9
View File
@@ -1,11 +1,17 @@
param( param(
[string]$Remote = "origin", [string]$Remote = "akai-tools",
[string]$Branch = "codex/readest-inline-review-mode", [string]$Branch = "codex/readest-web-translation-base",
[switch]$Web,
[switch]$Desktop,
[switch]$SkipPull, [switch]$SkipPull,
[switch]$CheckOnly, [switch]$CheckOnly,
[switch]$KeepRunning [switch]$KeepRunning
) )
if ($Web -and $Desktop) {
throw "-Web and -Desktop cannot be used together. Web is the default mode."
}
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
@@ -42,6 +48,17 @@ function Add-PathIfExists {
} }
} }
function Test-SubmoduleCheckout {
param([string]$RelativePath)
$fullPath = Join-Path $RepoRoot $RelativePath
if (-not (Test-Path -LiteralPath (Join-Path $fullPath ".git"))) {
return $false
}
& git -C $fullPath rev-parse --is-inside-work-tree 2>$null | Out-Null
return $LASTEXITCODE -eq 0
}
function Stop-OldReadestDev { function Stop-OldReadestDev {
if ($KeepRunning) { if ($KeepRunning) {
return return
@@ -62,14 +79,54 @@ function Stop-OldReadestDev {
ForEach-Object { ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
} }
$portConnection = Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $portConnection) {
return
}
$ownerPid = $portConnection.OwningProcess
$processLineage = @()
$lineagePid = $ownerPid
while ($lineagePid -and $processLineage.Count -lt 8) {
$lineageProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $lineagePid" -ErrorAction SilentlyContinue
if (-not $lineageProcess) {
break
}
$processLineage += $lineageProcess
$lineagePid = $lineageProcess.ParentProcessId
}
$isReadestDev = $processLineage | Where-Object {
$_.Name -ieq "Readest.exe" -or
($_.CommandLine -and
$_.CommandLine -match "(?i)readest" -and
$_.CommandLine -match "(?i)next(.+?)dev|start-server\.js|@readest/readest-app|tauri(.+?)dev")
}
if (-not $isReadestDev) {
$owner = $processLineage | Select-Object -First 1
$ownerDescription = if ($owner.CommandLine) { $owner.CommandLine } else { "$($owner.Name) (PID $ownerPid)" }
throw "Port 3000 is occupied by an unrelated process: $ownerDescription. Stop it or launch Readest after freeing port 3000."
}
Write-Host "Stopping old Readest/Next listener on port 3000 (PID $ownerPid)." -ForegroundColor Yellow
Stop-Process -Id $ownerPid -Force -ErrorAction SilentlyContinue
for ($attempt = 0; $attempt -lt 20; $attempt++) {
Start-Sleep -Milliseconds 250
if (-not (Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue)) {
return
}
}
throw "Port 3000 is still occupied after stopping the old Readest/Next process. Close the old development terminal and try again."
} }
function Assert-CleanWorktree { function Assert-CleanWorktree {
$status = (& git -C $RepoRoot status --porcelain) $status = @(& git -C $RepoRoot status --porcelain --untracked-files=no --ignore-submodules=dirty)
if ($status) { if ($status) {
Write-Host "" Write-Host ""
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
& git -C $RepoRoot status --short $status | ForEach-Object { Write-Host $_ }
throw "Commit, stash, or discard local changes before launching the latest organization version." throw "Commit, stash, or discard local changes before launching the latest organization version."
} }
} }
@@ -84,7 +141,11 @@ try {
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";") $env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu" $env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
foreach ($command in @("git", "pnpm", "cargo")) { $requiredCommands = @("git", "pnpm")
if ($Desktop) {
$requiredCommands += "cargo"
}
foreach ($command in $requiredCommands) {
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
throw "Required command not found: $command" throw "Required command not found: $command"
} }
@@ -94,6 +155,7 @@ try {
Write-Host "Repository: $RepoRoot" Write-Host "Repository: $RepoRoot"
Write-Host "Remote: $Remote" Write-Host "Remote: $Remote"
Write-Host "Branch: $Branch" Write-Host "Branch: $Branch"
Write-Host "Mode: $(if ($Desktop) { 'Desktop (Tauri)' } else { 'Web (Fast Refresh)' })"
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim() $beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
@@ -123,7 +185,26 @@ try {
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim() $afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
Write-Step "Updating submodules" Write-Step "Updating submodules"
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive") $requiredSubmodules = @(
"packages/foliate-js",
"packages/simplecc-wasm",
"packages/js-mdict"
)
if ($Desktop) {
$requiredSubmodules += "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"
}
$missingSubmodules = @($requiredSubmodules | Where-Object { -not (Test-SubmoduleCheckout $_) })
if ($missingSubmodules.Count -gt 0) {
$submoduleArgs = @(
"-C", $RepoRoot,
"-c", "http.version=HTTP/1.1",
"-c", "submodule.fetchJobs=1",
"submodule", "update", "--init", "--depth", "1", "--"
) + $missingSubmodules
Invoke-Checked "git" $submoduleArgs
} else {
Write-Host "Required submodules are already initialized."
}
$changedFiles = @() $changedFiles = @()
if ($beforeHead -ne $afterHead) { if ($beforeHead -ne $afterHead) {
@@ -141,6 +222,22 @@ try {
Invoke-Checked "pnpm" @("install", "--frozen-lockfile") Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
} }
$vendorSentinel = Join-Path $RepoRoot "apps\readest-app\public\vendor\pdfjs\pdf.min.mjs"
if ($needsInstall -or -not (Test-Path -LiteralPath $vendorSentinel)) {
Write-Step "Preparing browser runtime assets"
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "setup-vendors")
}
if ($Desktop) {
$tursoRoot = Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"
$tursoOutput = Join-Path $tursoRoot "dist\index.js"
if (-not (Test-Path -LiteralPath $tursoOutput)) {
Write-Step "Building Tauri plugin dependencies"
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "install", "--ignore-workspace", "--frozen-lockfile")
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "build")
}
}
if ($CheckOnly) { if ($CheckOnly) {
Write-Step "Launcher check complete" Write-Step "Launcher check complete"
exit 0 exit 0
@@ -148,9 +245,15 @@ try {
Stop-OldReadestDev Stop-OldReadestDev
Write-Step "Starting Readest desktop with the latest code" if ($Desktop) {
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server." Write-Step "Starting Readest desktop with the latest code"
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev") Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
} else {
Write-Step "Starting Readest web development server"
Write-Host "Open http://localhost:3000. Saved React and CSS changes appear through Fast Refresh."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "dev-web")
}
} catch { } catch {
Write-Host "" Write-Host ""
Write-Host "Readest launcher failed:" -ForegroundColor Red Write-Host "Readest launcher failed:" -ForegroundColor Red
+28
View File
@@ -0,0 +1,28 @@
$ErrorActionPreference = "Stop"
$ScriptPath = Join-Path $PSScriptRoot "open-readest-latest.ps1"
$ScriptText = Get-Content -LiteralPath $ScriptPath -Raw
function Assert-Match {
param(
[string]$Pattern,
[string]$Message
)
if ($ScriptText -notmatch $Pattern) {
throw $Message
}
}
Assert-Match '\[string\]\$Remote\s*=\s*"akai-tools"' "Launcher must pull from akai-tools by default."
Assert-Match '\[switch\]\$Desktop' "Launcher must expose an explicit desktop mode."
Assert-Match 'if\s*\(\$Desktop\)[\s\S]*tauri[\s\S]*else[\s\S]*dev-web' "Launcher must default to dev-web while preserving Tauri."
Assert-Match '\$requiredCommands\s*=\s*@\("git",\s*"pnpm"\)' "Web mode must not require Cargo."
Assert-Match 'submodule\.fetchJobs=1' "Launcher must initialize missing submodules conservatively."
Assert-Match 'status\s+--porcelain\s+--untracked-files=no\s+--ignore-submodules=dirty' "Clean check must ignore runtime logs and dirty submodule contents."
Assert-Match 'Get-NetTCPConnection[^\r\n]*-LocalPort\s+3000' "Launcher must inspect the Tauri dev port before starting."
Assert-Match 'Stop-Process[^\r\n]*-Id\s+\$ownerPid' "Launcher must stop an old Readest/Next process that owns port 3000."
Assert-Match 'Port 3000 is occupied' "Launcher must report an actionable error for an unrelated port owner."
Assert-Match 'Port 3000 is still occupied' "Launcher must wait for the previous dev server to release the port."
Write-Host "open-readest-latest launcher checks passed."