1 Commits

Author SHA1 Message Date
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
27 changed files with 952 additions and 3360 deletions
-1
View File
@@ -91,4 +91,3 @@ docs/superpowers
# Word Lens build-input corpora (ECDICT, WikDict, FrequencyWords, etc.) — large, # Word Lens build-input corpora (ECDICT, WikDict, FrequencyWords, etc.) — large,
# downloaded on demand by scripts/build-wordlens-data.mjs; cached locally, not committed. # downloaded on demand by scripts/build-wordlens-data.mjs; cached locally, not committed.
/data/wordlens/.sources/ /data/wordlens/.sources/
.codex-dev-*.log
+2 -2
View File
@@ -52,8 +52,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": "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-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-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-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-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,34 +0,0 @@
import { describe, expect, test } from 'vitest';
import {
canOpenTranslationOutput,
canResumeTranslationJob,
isActiveTranslationJob,
isTerminalTranslationJob,
translationJobStatusLabel,
} from '@/app/full-book-translation/translationJobPresentation';
describe('full-book translation job presentation', () => {
test('treats completed jobs and failures as terminal states', () => {
expect(isTerminalTranslationJob('completed')).toBe(true);
expect(isTerminalTranslationJob('failed')).toBe(true);
expect(isActiveTranslationJob('completed')).toBe(false);
});
test('allows failed jobs to resume from their checkpoint', () => {
expect(canResumeTranslationJob('failed')).toBe(true);
expect(canResumeTranslationJob('paused')).toBe(true);
expect(canResumeTranslationJob('cancelled')).toBe(false);
});
test('opens completed outputs when an output session exists', () => {
expect(canOpenTranslationOutput('completed', 'output-1')).toBe(true);
expect(canOpenTranslationOutput('failed', 'output-1')).toBe(false);
expect(canOpenTranslationOutput('completed', '')).toBe(false);
});
test('uses readable Chinese labels instead of backend status codes', () => {
expect(translationJobStatusLabel('completed')).toBe('已完成');
expect(translationJobStatusLabel('failed')).toBe('失败');
});
});
@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { prepareBookForLocalEpubSidecar } from '@/services/bookToolLaunchService';
import { importLocalEpubFile } from '@/services/localEpubSidecarService';
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
vi.mock('@/services/localEpubSidecarService', () => ({
importLocalEpubFile: vi.fn(),
}));
const book = { hash: 'hash-1', title: '测试书', format: 'EPUB' } as Book;
describe('prepareBookForLocalEpubSidecar', () => {
beforeEach(() => vi.mocked(importLocalEpubFile).mockReset());
test('uploads IndexedDB-backed content on web instead of returning its logical key', async () => {
const file = new File(['epub'], '测试书.epub', { type: 'application/epub+zip' });
const appService = {
appPlatform: 'web',
loadBookContent: vi.fn().mockResolvedValue({ book, file }),
resolveNativeBookFilePath: vi.fn(),
} as unknown as AppService;
vi.mocked(importLocalEpubFile).mockResolvedValue({ id: 'session-1' });
const result = await prepareBookForLocalEpubSidecar(appService, book, 'http://localhost:5177');
expect(result).toEqual({ kind: 'session', session: { id: 'session-1' } });
expect(importLocalEpubFile).toHaveBeenCalledWith('http://localhost:5177', file);
expect(appService.resolveNativeBookFilePath).not.toHaveBeenCalled();
});
test('keeps a real native filesystem path on desktop', async () => {
const appService = {
appPlatform: 'windows',
resolveNativeBookFilePath: vi.fn().mockResolvedValue('C:/Books/book.epub'),
loadBookContent: vi.fn(),
} as unknown as AppService;
const result = await prepareBookForLocalEpubSidecar(appService, book, 'http://localhost:5177');
expect(result).toEqual({ kind: 'path', epubPath: 'C:/Books/book.epub' });
expect(appService.loadBookContent).not.toHaveBeenCalled();
});
});
@@ -1,105 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { sidecarApi } from '@/services/localEpubSidecarService';
import {
confirmTranslationPreflight,
createLibrarySeries,
deleteLibrarySeries,
loadTranslationPreflight,
renameLibrarySeries,
assignBookToSeries,
} from '@/services/fullBookTranslationService';
import { importLocalEpubFile } from '@/services/localEpubSidecarService';
vi.mock('@/services/localEpubSidecarService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/localEpubSidecarService')>();
return { ...actual, sidecarApi: vi.fn() };
});
describe('fullBookTranslationService', () => {
beforeEach(() => {
vi.mocked(sidecarApi).mockReset();
});
test('uses the independent series-management endpoint', async () => {
vi.mocked(sidecarApi).mockResolvedValue({ sessions: [], series: [] });
await createLibrarySeries('http://127.0.0.1:1234', '系列一');
await renameLibrarySeries('http://127.0.0.1:1234', 'series-1', '系列二');
await assignBookToSeries('http://127.0.0.1:1234', 'book-1', 'series-1');
await deleteLibrarySeries('http://127.0.0.1:1234', 'series-1');
expect(sidecarApi).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:1234', '/api/library/series', {
method: 'POST',
body: JSON.stringify({ action: 'create', title: '系列一' }),
});
expect(sidecarApi).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:1234', '/api/library/series', {
method: 'POST',
body: JSON.stringify({ action: 'rename', series_id: 'series-1', title: '系列二' }),
});
expect(sidecarApi).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:1234', '/api/library/series', {
method: 'POST',
body: JSON.stringify({ action: 'assign', session_id: 'book-1', series_id: 'series-1' }),
});
expect(sidecarApi).toHaveBeenNthCalledWith(4, 'http://127.0.0.1:1234', '/api/library/series', {
method: 'POST',
body: JSON.stringify({ action: 'delete', series_id: 'series-1' }),
});
});
test('loads and confirms terminology preflight by session', async () => {
vi.mocked(sidecarApi).mockResolvedValue({
session_id: 'book-1',
candidates: [],
candidate_count: 0,
confirmed: false,
});
await loadTranslationPreflight('http://127.0.0.1:1234', 'book-1', 'C:/terms/mingcibiao.json');
await confirmTranslationPreflight(
'http://127.0.0.1:1234',
'book-1',
'C:/terms/mingcibiao.json',
);
expect(sidecarApi).toHaveBeenNthCalledWith(
1,
'http://127.0.0.1:1234',
'/api/session/book-1/translation-preflight?glossary_path=C%3A%2Fterms%2Fmingcibiao.json',
);
expect(sidecarApi).toHaveBeenNthCalledWith(
2,
'http://127.0.0.1:1234',
'/api/session/book-1/translation-preflight',
{
method: 'POST',
body: JSON.stringify({
glossary_path: 'C:/terms/mingcibiao.json',
confirmed: true,
}),
},
);
});
});
describe('local EPUB sidecar uploads', () => {
test('uploads browser-backed EPUB bytes instead of sending a synthetic filesystem path', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 'uploaded-session' }),
});
vi.stubGlobal('fetch', fetchMock);
const file = new File(['epub bytes'], 'book.epub', { type: 'application/epub+zip' });
const session = await importLocalEpubFile('http://127.0.0.1:5177', file);
expect(session.id).toBe('uploaded-session');
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('http://127.0.0.1:5177/api/upload');
expect(options.method).toBe('POST');
expect(options.body).toBeInstanceOf(FormData);
const uploaded = (options.body as FormData).get('epub') as File;
expect(uploaded.name).toBe('book.epub');
expect(await uploaded.text()).toBe('epub bytes');
});
});
@@ -13,12 +13,6 @@ import {
sidecarApi, sidecarApi,
switchReviewGlossaryPath, switchReviewGlossaryPath,
} from '@/services/reviewEditorService'; } from '@/services/reviewEditorService';
import {
controlTranslationJob,
listTranslationJobs,
loadTranslationPlan,
probeTranslationModels,
} from '@/services/fullBookTranslationService';
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -125,24 +119,3 @@ describe('reviewEditorService', () => {
} }
}); });
}); });
describe('fullBookTranslationService', () => {
test('keeps full-book task APIs outside the review editor service surface', async () => {
const fetchMock = stubJsonFetch();
await loadTranslationPlan('http://127.0.0.1:5177', 'source-a', 24);
await listTranslationJobs('http://127.0.0.1:5177', 'source-a');
await controlTranslationJob('http://127.0.0.1:5177', 'job-a', 'pause');
await probeTranslationModels('http://127.0.0.1:5177');
const urls = (fetchMock.mock.calls as unknown as Array<[string | URL]>).map(
(call) => new URL(String(call[0])),
);
expect(urls[0]?.pathname).toBe('/api/session/source-a/translation-plan');
expect(urls[0]?.searchParams.get('chunk_target')).toBe('24');
expect(urls[1]?.pathname).toBe('/api/translation/jobs');
expect(urls[1]?.searchParams.get('session_id')).toBe('source-a');
expect(urls[2]?.pathname).toBe('/api/translation/jobs/job-a/pause');
expect(urls[3]?.pathname).toBe('/api/gpt/models');
});
});
@@ -38,8 +38,6 @@ import {
navigateToLogin, navigateToLogin,
navigateToProfile, navigateToProfile,
navigateToLibrary, navigateToLibrary,
navigateToFullBookTranslation,
navigateToReviewEditor,
navigateToResetPassword, navigateToResetPassword,
navigateToUpdatePassword, navigateToUpdatePassword,
redirectToLibrary, redirectToLibrary,
@@ -249,22 +247,6 @@ describe('navigateToLibrary', () => {
}); });
}); });
describe('standalone book tools navigation', () => {
test('navigates review sessions to the review editor route', () => {
const router = mockRouter();
navigateToReviewEditor(router, new URLSearchParams({ session_id: 'review-session' }));
expect(router.push).toHaveBeenCalledWith('/review-editor?session_id=review-session');
});
test('navigates translation sessions to the independent full-book workspace', () => {
const router = mockRouter();
navigateToFullBookTranslation(router, new URLSearchParams({ session_id: 'source-session' }));
expect(router.push).toHaveBeenCalledWith('/full-book-translation?session_id=source-session');
});
});
describe('redirectToLibrary', () => { describe('redirectToLibrary', () => {
test('calls redirect to /library', () => { test('calls redirect to /library', () => {
redirectToLibrary(); redirectToLibrary();
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
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';
@@ -46,6 +46,8 @@ const venvPython = () =>
? path.join(venvRoot(), 'Scripts', 'python.exe') ? path.join(venvRoot(), 'Scripts', 'python.exe')
: path.join(venvRoot(), 'bin', 'python'); : path.join(venvRoot(), 'bin', 'python');
const venvPythonw = () => path.join(venvRoot(), 'Scripts', 'pythonw.exe');
const run = ( const run = (
command: string, command: string,
args: string[], args: string[],
@@ -138,6 +140,43 @@ const findRunningEditor = async () => {
return ''; return '';
}; };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForRunningEditor = async (timeoutMs = 45_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const url = await findRunningEditor();
if (url) return url;
await sleep(500);
}
return '';
};
const launchDetachedEditor = (serverPath: string) => {
const command =
process.platform === 'win32' && fs.existsSync(venvPythonw()) ? venvPythonw() : venvPython();
const child = spawn(
command,
[
serverPath,
'--review-root',
reviewRoot(),
'--host',
'127.0.0.1',
'--port',
String(DEFAULT_PORT),
'--no-browser',
],
{
cwd: toolRoot(),
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
child.unref();
};
const resolvePythonCommand = (): PythonCommand => { const resolvePythonCommand = (): PythonCommand => {
const candidates: PythonCommand[] = const candidates: PythonCommand[] =
process.platform === 'win32' process.platform === 'win32'
@@ -253,30 +292,10 @@ async function launchEditor(): Promise<LaunchResponse> {
ensureDependencies(); ensureDependencies();
fs.mkdirSync(reviewRoot(), { recursive: true }); fs.mkdirSync(reviewRoot(), { recursive: true });
const launch = run( launchDetachedEditor(serverPath);
venvPython(), const launchedUrl = normalizeLoopbackUrl(await waitForRunningEditor());
[
serverPath,
'--review-root',
reviewRoot(),
'--host',
'127.0.0.1',
'--port',
String(DEFAULT_PORT),
'--daemon',
'--no-browser',
],
{ cwd: bundledToolRoot, timeout: 45_000 },
);
if (!launch.ok) {
throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`);
}
const launchedUrl = normalizeLoopbackUrl(
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()),
);
if (!launchedUrl) { if (!launchedUrl) {
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); throw new Error('审校器已启动但未返回访问地址,请稍后重试');
} }
return { return {
@@ -1,987 +0,0 @@
'use client';
import {
ArrowLeft,
AlertTriangle,
BookOpenCheck,
CheckCircle2,
ChevronRight,
CirclePause,
CirclePlay,
FolderCog,
FolderPlus,
Languages,
ListChecks,
LoaderCircle,
OctagonX,
Pencil,
RefreshCw,
Save,
ScanSearch,
Settings2,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
assignBookToSeries,
confirmTranslationPreflight,
controlTranslationJob,
createLibrarySeries,
deleteLibrarySeries,
loadTranslationPreflight,
renameLibrarySeries,
type TranslationJobSummary,
type TranslationPlanPayload,
type TranslationPreflightPayload,
} from '@/services/fullBookTranslationService';
import {
launchLocalEpubSidecar,
type LocalEpubSessionSummary,
sidecarApi,
} from '@/services/localEpubSidecarService';
import {
canOpenTranslationOutput,
canResumeTranslationJob,
isActiveTranslationJob,
isTerminalTranslationJob,
translationJobStatusLabel,
} from './translationJobPresentation';
type WorkspaceState = 'launching' | 'ready' | 'failed';
type TranslationStatus = 'all' | 'untranslated' | 'translating' | 'translated';
type LibraryBook = LocalEpubSessionSummary & {
authors?: string[];
cover_url?: string;
translation_status?: Exclude<TranslationStatus, 'all'>;
translation_job?: TranslationJobSummary | null;
};
type TranslationLibrary = {
sessions: LibraryBook[];
series: Array<{ id: string; title: string; count: number; books?: string[] }>;
};
type GptConfig = {
configured?: boolean;
base_url?: string;
model?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
glossary_path?: string;
};
type TranslationSource = {
session: LibraryBook;
source_count: number;
series_config?: Partial<GptConfig> & { series_id?: string };
};
type TranslationJobPayload = {
job_id?: string;
job?: TranslationJobSummary;
};
const statusOptions: Array<{ id: TranslationStatus; label: string }> = [
{ id: 'all', label: '全部' },
{ id: 'untranslated', label: '未翻译' },
{ id: 'translating', label: '翻译中' },
{ id: 'translated', label: '已翻译' },
];
const formatPercent = (value?: number) =>
`${Math.max(0, Math.min(100, Number(value || 0))).toFixed(0)}%`;
function readLaunchContext() {
const params = new URLSearchParams(window.location.search);
let stored: { epubPath?: string; sessionId?: string } = {};
try {
stored = JSON.parse(sessionStorage.getItem('fullBookTranslationLaunchContext') || '{}');
} catch (_error) {
stored = {};
}
return {
epubPath: params.get('epub_path') || stored.epubPath,
sessionId: params.get('session_id') || stored.sessionId,
};
}
export default function FullBookTranslationWorkspace() {
const router = useRouter();
const [state, setState] = useState<WorkspaceState>('launching');
const [message, setMessage] = useState('正在启动本地翻译服务……');
const [baseUrl, setBaseUrl] = useState('');
const [library, setLibrary] = useState<TranslationLibrary>({ sessions: [], series: [] });
const [seriesId, setSeriesId] = useState('all');
const [status, setStatus] = useState<TranslationStatus>('all');
const [selectedBook, setSelectedBook] = useState<LibraryBook | null>(null);
const [source, setSource] = useState<TranslationSource | null>(null);
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
const [job, setJob] = useState<TranslationJobSummary | null>(null);
const [jobLogs, setJobLogs] = useState<Array<{ ts?: string; message?: string }>>([]);
const [preflight, setPreflight] = useState<TranslationPreflightPayload | null>(null);
const [preflightLoading, setPreflightLoading] = useState(false);
const [chunkTarget, setChunkTarget] = useState(24);
const [qualityMode, setQualityMode] = useState<'standard' | 'quality'>('quality');
const [gpt, setGpt] = useState<GptConfig>({ model: 'gpt-5.6-sol' });
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const pollRef = useRef<number | null>(null);
const clearPoll = useCallback(() => {
if (pollRef.current !== null) window.clearTimeout(pollRef.current);
pollRef.current = null;
}, []);
const loadLibrary = useCallback(async (url: string) => {
const payload = await sidecarApi<TranslationLibrary>(url, '/api/library');
setLibrary(payload);
return payload;
}, []);
const launch = useCallback(async () => {
setState('launching');
setMessage('正在启动本地翻译服务……');
try {
const context = readLaunchContext();
const launched = await launchLocalEpubSidecar(context);
setBaseUrl(launched.url);
const nextLibrary = await loadLibrary(launched.url);
const requested = launched.sessionId || context.sessionId;
const initialBook = nextLibrary.sessions.find((book) => book.id === requested) || null;
setSelectedBook(initialBook);
sessionStorage.removeItem('fullBookTranslationLaunchContext');
setState('ready');
setMessage('');
} catch (error) {
setState('failed');
setMessage(error instanceof Error ? error.message : String(error));
}
}, [loadLibrary]);
useEffect(() => {
void launch();
return clearPoll;
}, [clearPoll, launch]);
const pollJob = useCallback(
async (jobId: string) => {
if (!baseUrl) return;
try {
const payload = await sidecarApi<{ job?: TranslationJobSummary }>(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
);
if (!payload.job) return;
setJob(payload.job);
setJobLogs(payload.job.logs || []);
if (isTerminalTranslationJob(payload.job.status)) {
clearPoll();
await loadLibrary(baseUrl);
return;
}
pollRef.current = window.setTimeout(() => void pollJob(jobId), 1200);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
},
[baseUrl, clearPoll, loadLibrary],
);
useEffect(() => {
if (!baseUrl || !selectedBook) {
setSource(null);
setPlan(null);
setJob(null);
setPreflight(null);
return;
}
let cancelled = false;
const load = async () => {
setMessage('正在读取全书结构与系列配置……');
try {
const [sourcePayload, defaultsPayload, jobsPayload] = await Promise.all([
sidecarApi<TranslationSource>(
baseUrl,
`/api/session/${encodeURIComponent(selectedBook.id)}/translation-source`,
),
sidecarApi<{ gpt?: GptConfig }>(baseUrl, '/api/translation/defaults'),
sidecarApi<{ jobs?: TranslationJobSummary[] }>(
baseUrl,
`/api/translation/jobs?session_id=${encodeURIComponent(selectedBook.id)}`,
),
]);
if (cancelled) return;
const seriesConfig = sourcePayload.series_config || {};
setSource(sourcePayload);
const effectiveGpt = {
...(defaultsPayload.gpt || {}),
glossary_path: seriesConfig.glossary_path || defaultsPayload.gpt?.glossary_path || '',
translation_prompt:
seriesConfig.translation_prompt || defaultsPayload.gpt?.translation_prompt || '',
format_prompt: seriesConfig.format_prompt || defaultsPayload.gpt?.format_prompt || '',
character_prompt:
seriesConfig.character_prompt || defaultsPayload.gpt?.character_prompt || '',
};
setGpt(effectiveGpt);
const preflightPayload = await loadTranslationPreflight(
baseUrl,
selectedBook.id,
effectiveGpt.glossary_path || '',
);
if (cancelled) return;
setPreflight(preflightPayload);
const active = (jobsPayload.jobs || []).find((item) => isActiveTranslationJob(item.status));
setJob(active || jobsPayload.jobs?.[0] || null);
if (active?.id) {
clearPoll();
pollRef.current = window.setTimeout(() => void pollJob(active.id), 300);
}
setMessage('');
} catch (error) {
if (!cancelled) setMessage(error instanceof Error ? error.message : String(error));
}
};
void load();
return () => {
cancelled = true;
clearPoll();
};
}, [baseUrl, clearPoll, pollJob, selectedBook]);
const filteredBooks = useMemo(
() =>
library.sessions.filter(
(book) =>
(seriesId === 'all' || book.series_id === seriesId) &&
(status === 'all' || book.translation_status === status),
),
[library.sessions, seriesId, status],
);
const adoptLibrary = (nextLibrary: TranslationLibrary) => {
setLibrary(nextLibrary);
if (selectedBook) {
setSelectedBook(nextLibrary.sessions.find((book) => book.id === selectedBook.id) || null);
}
};
const createSeries = async () => {
if (!baseUrl) return;
const title = window.prompt('新系列名称');
if (!title?.trim()) return;
try {
await createLibrarySeries(baseUrl, title.trim());
adoptLibrary(await loadLibrary(baseUrl));
setMessage('系列已创建。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const renameSeries = async () => {
if (!baseUrl || seriesId === 'all') return;
const current = library.series.find((item) => item.id === seriesId);
if (!current) return;
const title = window.prompt('系列名称', current.title);
if (!title?.trim() || title.trim() === current.title) return;
try {
await renameLibrarySeries(baseUrl, seriesId, title.trim());
adoptLibrary(await loadLibrary(baseUrl));
setMessage('系列已重命名。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const deleteSeries = async () => {
if (!baseUrl || seriesId === 'all') return;
const current = library.series.find((item) => item.id === seriesId);
if (!current || current.count > 0) return;
if (!window.confirm(`删除空系列“${current.title}”?`)) return;
try {
await deleteLibrarySeries(baseUrl, seriesId);
adoptLibrary(await loadLibrary(baseUrl));
setSeriesId('all');
setMessage('空系列已删除。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const assignSelectedBook = async (nextSeriesId: string) => {
if (!baseUrl || !selectedBook || !nextSeriesId) return;
try {
await assignBookToSeries(baseUrl, selectedBook.id, nextSeriesId);
adoptLibrary(await loadLibrary(baseUrl));
setMessage('书籍已归入系列。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const scanTerms = async () => {
if (!baseUrl || !selectedBook) return;
setPreflightLoading(true);
setMessage('正在扫描候选术语……');
try {
const payload = await loadTranslationPreflight(
baseUrl,
selectedBook.id,
gpt.glossary_path || '',
);
setPreflight(payload);
setMessage('候选术语扫描已完成。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setPreflightLoading(false);
}
};
const confirmTerms = async () => {
if (!baseUrl || !selectedBook || !preflight) return;
if (!window.confirm('确认已核对候选术语和正式术语表,并以当前结果启动翻译?')) return;
setPreflightLoading(true);
try {
setPreflight(
await confirmTranslationPreflight(baseUrl, selectedBook.id, gpt.glossary_path || ''),
);
setMessage('术语准备已确认。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setPreflightLoading(false);
}
};
const previewPlan = async () => {
if (!baseUrl || !selectedBook) return;
setMessage('正在规划 chunk……');
try {
const payload = await sidecarApi<TranslationPlanPayload>(
baseUrl,
`/api/session/${encodeURIComponent(selectedBook.id)}/translation-plan?chunk_target=${chunkTarget}`,
);
setPlan(payload);
setMessage('分块计划已更新。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const saveSettings = async () => {
if (!baseUrl) return;
setSaving(true);
try {
await sidecarApi(baseUrl, '/api/gpt/config', {
method: 'POST',
body: JSON.stringify({ ...gpt, api_key: apiKey, keep_existing: true }),
});
if (selectedBook?.series_id) {
await sidecarApi(
baseUrl,
`/api/series/${encodeURIComponent(selectedBook.series_id)}/config`,
{
method: 'POST',
body: JSON.stringify({
glossary_path: gpt.glossary_path,
translation_prompt: gpt.translation_prompt,
format_prompt: gpt.format_prompt,
character_prompt: gpt.character_prompt,
}),
},
);
}
setApiKey('');
setMessage('系列配置已保存。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const startTranslation = async () => {
if (!baseUrl || !selectedBook) return;
if (!preflight?.confirmed) {
setMessage('请先扫描并确认候选术语。');
return;
}
if (
!window.confirm(
`将按当前 chunk 计划启动整本书${qualityMode === 'quality' ? '高质量' : '标准'}翻译。确认继续吗?`,
)
)
return;
setMessage('正在创建全书翻译任务……');
try {
await saveSettings();
const payload = await sidecarApi<TranslationJobPayload>(baseUrl, '/api/translation/start', {
method: 'POST',
body: JSON.stringify({
session_id: selectedBook.id,
output_mode: 'bilingual',
range_mode: 'all',
chunk_target: chunkTarget,
quality_mode: qualityMode,
model: gpt.model,
base_url: gpt.base_url,
glossary_path: gpt.glossary_path,
translation_prompt: gpt.translation_prompt,
format_prompt: gpt.format_prompt,
character_prompt: gpt.character_prompt,
}),
});
const nextJob = payload.job || null;
setJob(nextJob);
if (nextJob?.id || payload.job_id) {
const jobId = nextJob?.id || payload.job_id || '';
clearPoll();
pollRef.current = window.setTimeout(() => void pollJob(jobId), 400);
}
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const control = async (action: 'pause' | 'resume' | 'cancel') => {
if (!baseUrl || !job?.id) return;
try {
const payload = await controlTranslationJob(baseUrl, job.id, action);
setJob(payload.job);
if (action === 'resume') {
clearPoll();
pollRef.current = window.setTimeout(() => void pollJob(job.id), 300);
}
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
if (state !== 'ready') {
return (
<main className='bg-base-200 text-base-content flex h-dvh items-center justify-center p-6'>
<section className='eink-bordered border-base-300 bg-base-100 w-full max-w-lg rounded-md border p-6'>
<LoaderCircle className='h-7 w-7 animate-spin' />
<h1 className='mt-4 text-xl font-semibold'></h1>
<p className='text-base-content/70 mt-2 text-sm'>{message}</p>
{state === 'failed' ? (
<button className='btn btn-primary mt-5 rounded-md' type='button' onClick={launch}>
</button>
) : null}
</section>
</main>
);
}
return (
<main className='bg-base-200 text-base-content flex h-dvh min-h-dvh flex-col overflow-hidden'>
<header className='border-base-300 bg-base-100 flex h-14 flex-shrink-0 items-center justify-between border-b px-4'>
<div className='flex min-w-0 items-center gap-3'>
<button
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
type='button'
title='返回书库'
aria-label='返回书库'
onClick={() => router.push('/library')}
>
<ArrowLeft className='h-5 w-5' />
</button>
<div className='min-w-0'>
<h1 className='truncate text-base font-semibold'></h1>
<p className='text-base-content/60 truncate text-xs'> EPUB</p>
</div>
</div>
<button
className='btn btn-ghost h-9 min-h-9 rounded-md px-3'
type='button'
onClick={() => void loadLibrary(baseUrl)}
>
<RefreshCw className='h-4 w-4' />
</button>
</header>
<div className='grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[240px_300px_minmax(0,1fr)]'>
<aside className='border-base-300 bg-base-100 min-h-0 overflow-auto border-b p-3 lg:border-r lg:border-b-0'>
<div className='mb-3 flex items-center justify-between gap-2 px-2'>
<div className='flex items-center gap-2'>
<FolderCog className='h-4 w-4' />
<h2 className='text-sm font-semibold'></h2>
</div>
<button
className='btn btn-ghost h-8 min-h-8 w-8 rounded-md p-0'
type='button'
title='创建系列'
aria-label='创建系列'
onClick={() => void createSeries()}
>
<FolderPlus className='h-4 w-4' />
</button>
</div>
<button
className={`w-full rounded-md px-3 py-2 text-left text-sm ${seriesId === 'all' ? 'bg-base-200 font-semibold' : 'hover:bg-base-200'}`}
type='button'
onClick={() => setSeriesId('all')}
>
<span className='float-right'>{library.sessions.length}</span>
</button>
{library.series.map((series) => (
<button
className={`mt-1 w-full rounded-md px-3 py-2 text-left text-sm ${seriesId === series.id ? 'bg-base-200 font-semibold' : 'hover:bg-base-200'}`}
type='button'
key={series.id}
onClick={() => setSeriesId(series.id)}
>
<span className='block truncate'>{series.title}</span>
<span className='text-base-content/50 text-xs'>{series.count} </span>
</button>
))}
{seriesId !== 'all' ? (
<div className='border-base-300 mt-3 flex gap-1 border-t pt-3'>
<button
className='btn btn-ghost eink-bordered h-8 min-h-8 flex-1 rounded-md px-2 text-xs'
type='button'
onClick={() => void renameSeries()}
>
<Pencil className='h-3.5 w-3.5' />
</button>
<button
className='btn btn-ghost eink-bordered h-8 min-h-8 flex-1 rounded-md px-2 text-xs'
type='button'
disabled={(library.series.find((item) => item.id === seriesId)?.count || 0) > 0}
title={
(library.series.find((item) => item.id === seriesId)?.count || 0) > 0
? '请先移走系列中的书籍'
: '删除空系列'
}
onClick={() => void deleteSeries()}
>
<Trash2 className='h-3.5 w-3.5' />
</button>
</div>
) : null}
</aside>
<section className='border-base-300 bg-base-100 flex min-h-0 flex-col border-b lg:border-r lg:border-b-0'>
<div className='border-base-300 grid gap-3 border-b p-3'>
<div className='grid grid-cols-4 gap-1' role='group' aria-label='翻译状态'>
{statusOptions.map((option) => (
<button
className={`rounded-md px-2 py-2 text-xs ${status === option.id ? 'bg-base-content text-base-100' : 'bg-base-200 hover:bg-base-300'}`}
type='button'
key={option.id}
onClick={() => setStatus(option.id)}
>
{option.label}
</button>
))}
</div>
<p className='text-base-content/60 text-xs'>{filteredBooks.length} </p>
</div>
<div className='min-h-0 flex-1 overflow-auto p-2'>
{filteredBooks.map((book) => (
<button
type='button'
key={book.id}
className={`eink-bordered mb-2 grid w-full grid-cols-[1fr_auto] items-center gap-3 rounded-md border p-3 text-left ${selectedBook?.id === book.id ? 'border-primary bg-primary/5' : 'border-base-300 hover:bg-base-200'}`}
onClick={() => setSelectedBook(book)}
>
<span className='min-w-0'>
<strong className='block truncate text-sm'>
{book.title || book.source_name}
</strong>
<span className='text-base-content/60 block truncate text-xs'>
{book.series || '未分类'} ·{' '}
{statusOptions.find((item) => item.id === book.translation_status)?.label ||
'未翻译'}
</span>
</span>
<ChevronRight className='h-4 w-4' />
</button>
))}
</div>
</section>
<section className='min-h-0 overflow-auto p-4'>
{!selectedBook ? (
<div className='flex min-h-full items-center justify-center'>
<div className='text-center'>
<Languages className='mx-auto h-9 w-9' />
<h2 className='mt-3 text-lg font-semibold'></h2>
</div>
</div>
) : (
<div className='mx-auto grid max-w-5xl gap-4'>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='flex flex-wrap items-end justify-between gap-3'>
<div>
<h2 className='text-lg font-semibold'>
{selectedBook.title || selectedBook.source_name}
</h2>
<p className='text-base-content/60 mt-1 text-sm'>
{source ? `${source.source_count} 个正文块` : '正在扫描正文'} ·{' '}
{selectedBook.series || '未分类'}
</p>
</div>
<label className='grid min-w-44 gap-1 text-xs'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md'
value={selectedBook.series_id || ''}
disabled={!library.series.length}
onChange={(event) => void assignSelectedBook(event.target.value)}
>
<option value='' disabled>
{library.series.length ? '选择系列' : '请先创建系列'}
</option>
{library.series.map((series) => (
<option key={series.id} value={series.id}>
{series.title}
</option>
))}
</select>
</label>
</div>
</section>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<ScanSearch className='h-4 w-4' />
<div>
<h3 className='text-sm font-semibold'></h3>
<p className='text-base-content/60 text-xs'>
ruby
</p>
</div>
</div>
{preflight?.confirmed ? (
<span className='text-success flex items-center gap-1 text-xs font-semibold'>
<CheckCircle2 className='h-4 w-4' />
</span>
) : (
<span className='text-warning flex items-center gap-1 text-xs font-semibold'>
<AlertTriangle className='h-4 w-4' />
</span>
)}
</div>
<div className='mt-3 flex flex-wrap gap-2'>
<button
className='btn btn-ghost eink-bordered rounded-md'
type='button'
disabled={preflightLoading}
onClick={() => void scanTerms()}
>
<ScanSearch className='h-4 w-4' />
{preflightLoading ? '扫描中' : '扫描候选术语'}
</button>
<button
className='btn btn-primary rounded-md'
type='button'
disabled={!preflight || preflight.confirmed || preflightLoading}
onClick={() => void confirmTerms()}
>
<CheckCircle2 className='h-4 w-4' />
</button>
</div>
{preflight ? (
<div className='mt-3'>
<p className='text-base-content/60 text-xs'>
{preflight.source_count} · {preflight.glossary_count || 0}
· {preflight.candidate_count}
</p>
{preflight.candidates.length ? (
<div className='border-base-300 mt-3 max-h-48 overflow-auto rounded-md border'>
{preflight.candidates.map((candidate) => (
<div
className='border-base-300 grid gap-1 border-b p-3 text-xs last:border-b-0'
key={candidate.source}
>
<div className='flex justify-between gap-3'>
<strong>{candidate.source}</strong>
<span className='text-base-content/60'>{candidate.count} </span>
</div>
{candidate.example ? (
<p className='text-base-content/60 line-clamp-2'>
{candidate.example}
</p>
) : null}
</div>
))}
</div>
) : (
<p className='mt-2 text-sm'> ruby </p>
)}
</div>
) : null}
</section>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='mb-3 flex items-center gap-2'>
<ListChecks className='h-4 w-4' />
<h3 className='text-sm font-semibold'></h3>
</div>
<div className='grid gap-3 sm:grid-cols-[1fr_1fr_auto]'>
<label className='grid gap-1 text-xs'>
chunk
<input
className='input input-bordered eink-bordered h-9 rounded-md'
type='number'
min='1'
max='500'
value={chunkTarget}
onChange={(event) => setChunkTarget(Number(event.target.value) || 1)}
/>
</label>
<label className='grid gap-1 text-xs'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md'
value={qualityMode}
onChange={(event) =>
setQualityMode(event.target.value === 'standard' ? 'standard' : 'quality')
}
>
<option value='quality'> + + </option>
<option value='standard'> + + </option>
</select>
</label>
<button
className='btn btn-ghost eink-bordered self-end rounded-md'
type='button'
onClick={previewPlan}
>
</button>
</div>
{plan ? (
<div className='mt-4'>
<p className='text-sm'>
{plan.chunk_count} chunk{' '}
{plan.estimated_input_tokens.toLocaleString()} token
</p>
<div className='mt-2 max-h-48 overflow-auto'>
{plan.chunks.map((chunk) => (
<div
className='border-base-300 flex justify-between border-b py-2 text-xs'
key={chunk.id}
>
<span>{chunk.title || chunk.id}</span>
<span>
{chunk.item_count} · {chunk.estimated_input_tokens} token
</span>
</div>
))}
</div>
</div>
) : null}
</section>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='mb-3 flex items-center gap-2'>
<Settings2 className='h-4 w-4' />
<h3 className='text-sm font-semibold'></h3>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<label className='grid gap-1 text-xs'>
API Base URL
<input
className='input input-bordered eink-bordered h-9 rounded-md'
value={gpt.base_url || ''}
onChange={(event) =>
setGpt((current) => ({ ...current, base_url: event.target.value }))
}
/>
</label>
<label className='grid gap-1 text-xs'>
<input
className='input input-bordered eink-bordered h-9 rounded-md'
value={gpt.model || ''}
onChange={(event) =>
setGpt((current) => ({ ...current, model: event.target.value }))
}
/>
</label>
<label className='grid gap-1 text-xs sm:col-span-2'>
API Key
<input
className='input input-bordered eink-bordered h-9 rounded-md'
type='password'
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder='留空沿用已保存密钥'
/>
</label>
<label className='grid gap-1 text-xs sm:col-span-2'>
<input
className='input input-bordered eink-bordered h-9 rounded-md'
value={gpt.glossary_path || ''}
onChange={(event) => {
setGpt((current) => ({ ...current, glossary_path: event.target.value }));
setPreflight(null);
}}
placeholder='mingcibiao.json'
/>
</label>
<label className='grid gap-1 text-xs sm:col-span-2'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-24 rounded-md'
value={gpt.translation_prompt || ''}
onChange={(event) =>
setGpt((current) => ({
...current,
translation_prompt: event.target.value,
}))
}
/>
</label>
<label className='grid gap-1 text-xs'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-20 rounded-md'
value={gpt.format_prompt || ''}
onChange={(event) =>
setGpt((current) => ({ ...current, format_prompt: event.target.value }))
}
/>
</label>
<label className='grid gap-1 text-xs'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-20 rounded-md'
value={gpt.character_prompt || ''}
onChange={(event) =>
setGpt((current) => ({ ...current, character_prompt: event.target.value }))
}
/>
</label>
</div>
<div className='mt-3 flex flex-wrap gap-2'>
<button
className='btn btn-ghost eink-bordered rounded-md'
type='button'
onClick={saveSettings}
disabled={saving}
>
<Save className='h-4 w-4' />
</button>
<button
className='btn btn-primary rounded-md'
type='button'
onClick={startTranslation}
disabled={
!source?.source_count ||
!preflight?.confirmed ||
Boolean(job && isActiveTranslationJob(job.status))
}
>
<CirclePlay className='h-4 w-4' />
</button>
</div>
</section>
{job ? (
<section
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'
aria-live='polite'
>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>
· {translationJobStatusLabel(job.status)}
</h3>
<p className='text-base-content/60 text-xs'>
{job.progress?.message || job.id}
</p>
</div>
<strong>{formatPercent(job.progress?.percent)}</strong>
</div>
<progress
className='progress progress-primary mt-3 w-full'
value={job.progress?.percent || 0}
max='100'
/>
<div className='mt-3 flex flex-wrap gap-2'>
{job.status === 'running' ? (
<button
className='btn btn-ghost eink-bordered rounded-md'
type='button'
onClick={() => void control('pause')}
>
<CirclePause className='h-4 w-4' />
</button>
) : null}
{canResumeTranslationJob(job.status) ? (
<button
className='btn btn-primary rounded-md'
type='button'
onClick={() => void control('resume')}
>
<CirclePlay className='h-4 w-4' />
{job.status === 'failed' ? '从断点恢复' : '恢复'}
</button>
) : null}
{isActiveTranslationJob(job.status) ? (
<button
className='btn btn-ghost eink-bordered rounded-md'
type='button'
onClick={() => void control('cancel')}
>
<OctagonX className='h-4 w-4' />
</button>
) : null}
{canOpenTranslationOutput(job.status, job.output_session_id) ? (
<button
className='btn btn-primary rounded-md'
type='button'
onClick={() =>
router.push(
`/review-editor?session_id=${encodeURIComponent(job.output_session_id || '')}`,
)
}
>
<BookOpenCheck className='h-4 w-4' />
</button>
) : null}
</div>
{job.status === 'failed' ? (
<p className='text-error mt-3 text-sm'>
{job.error || '任务失败,可以从已保存的 chunk 断点恢复。'}
</p>
) : null}
{jobLogs.length ? (
<div className='bg-base-200 mt-3 max-h-40 overflow-auto rounded-md p-3 text-xs'>
{jobLogs.slice(-10).map((entry, index) => (
<p key={`${entry.ts || ''}-${index}`}>{entry.message}</p>
))}
</div>
) : null}
</section>
) : null}
{message ? (
<p
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
role='status'
>
{message}
</p>
) : null}
</div>
)}
</section>
</div>
</main>
);
}
@@ -1,5 +0,0 @@
import FullBookTranslationWorkspace from './FullBookTranslationWorkspace';
export default function FullBookTranslationPage() {
return <FullBookTranslationWorkspace />;
}
@@ -1,25 +0,0 @@
const ACTIVE_TRANSLATION_STATUSES = new Set(['pending', 'running', 'paused']);
const TERMINAL_TRANSLATION_STATUSES = new Set(['completed', 'failed', 'cancelled']);
const TRANSLATION_STATUS_LABELS: Record<string, string> = {
pending: '等待中',
running: '翻译中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
};
export const isActiveTranslationJob = (status: string) => ACTIVE_TRANSLATION_STATUSES.has(status);
export const isTerminalTranslationJob = (status: string) =>
TERMINAL_TRANSLATION_STATUSES.has(status);
export const canResumeTranslationJob = (status: string) =>
status === 'paused' || status === 'failed';
export const canOpenTranslationOutput = (status: string, outputSessionId?: string) =>
Boolean(outputSessionId) && status === 'completed';
export const translationJobStatusLabel = (status: string) =>
TRANSLATION_STATUS_LABELS[status] || status;
@@ -19,9 +19,7 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BooksGroup, ReadingStatus } from '@/types/book'; import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import { navigateToFullBookTranslation, navigateToReviewEditor } from '@/utils/nav'; import { navigateToReviewEditor } from '@/utils/nav';
import { prepareBookForLocalEpubSidecar } from '@/services/bookToolLaunchService';
import { ensureLocalEpubSidecar } from '@/services/localEpubSidecarService';
import { import {
getBookContextMenuItemIds, getBookContextMenuItemIds,
type BookContextMenuItemId, type BookContextMenuItemId,
@@ -167,34 +165,26 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
[isSelectMode, openBook, toggleSelection], [isSelectMode, openBook, toggleSelection],
); );
const openBookTool = useCallback( const openBookInReviewEditor = useCallback(
async (book: Book, tool: 'review' | 'translate') => { async (book: Book, mode: 'review' | 'translate') => {
const available = await makeBookAvailable(book); const available = await makeBookAvailable(book);
if (!available) return; if (!available) return;
if (!appService) return; const epubPath = appService?.isDesktopApp
let launchContext: { epubPath?: string; sessionId?: string }; ? await appService.resolveNativeBookFilePath(book)
try { : null;
const sidecar = await ensureLocalEpubSidecar(); if (appService?.isDesktopApp && !epubPath) {
const reference = await prepareBookForLocalEpubSidecar(appService, book, sidecar.url);
launchContext =
reference.kind === 'path'
? { epubPath: reference.epubPath }
: { sessionId: reference.session.id };
} catch (error) {
eventDispatcher.dispatch('toast', { eventDispatcher.dispatch('toast', {
message: error instanceof Error ? error.message : String(error), message: _('Book file is not available locally'),
type: 'warning', type: 'warning',
}); });
return; return;
} }
const contextKey = sessionStorage.setItem(
tool === 'review' ? 'reviewEditorLaunchContext' : 'fullBookTranslationLaunchContext'; 'reviewEditorLaunchContext',
sessionStorage.setItem(contextKey, JSON.stringify(launchContext)); JSON.stringify(epubPath ? { mode, epubPath } : { mode, bookHash: book.hash }),
if (tool === 'review') { );
navigateToReviewEditor(router); const params = new URLSearchParams(epubPath ? { mode } : { mode, book_hash: book.hash });
} else { navigateToReviewEditor(router, params);
navigateToFullBookTranslation(router);
}
}, },
[_, appService, makeBookAvailable, router], [_, appService, makeBookAvailable, router],
); );
@@ -270,13 +260,13 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
reviewInEpubEditor: { reviewInEpubEditor: {
text: '用 EPUB 审校器校对', text: '用 EPUB 审校器校对',
action: async () => { action: async () => {
await openBookTool(book, 'review'); await openBookInReviewEditor(book, 'review');
}, },
}, },
translateInEpubEditor: { translateInEpubEditor: {
text: '全书翻译', text: '用 EPUB 审校器翻译',
action: async () => { action: async () => {
await openBookTool(book, 'translate'); await openBookInReviewEditor(book, 'translate');
}, },
}, },
bilingual: { bilingual: {
@@ -4,6 +4,7 @@ import React from 'react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useEnv } from '@/context/EnvContext'; import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { useBookDataStore } from '@/store/bookDataStore'; 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';
@@ -31,8 +32,10 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const enabled = !!reviewState?.enabled; const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading; const loading = !!reviewState?.loading;
const canLaunchReviewEditor =
!!appService?.isDesktopApp || (isWebAppPlatform() && process.env['NODE_ENV'] === 'development');
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null; if (!canLaunchReviewEditor || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => { const handleToggleReviewMode = async () => {
if (appService?.isMobile) { if (appService?.isMobile) {
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { invoke } from '@tauri-apps/api/core';
import clsx from 'clsx'; import clsx from 'clsx';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { import {
@@ -9,6 +10,7 @@ import {
Download, Download,
ExternalLink, ExternalLink,
FileText, FileText,
Languages,
RefreshCw, RefreshCw,
Rocket, Rocket,
Save, Save,
@@ -18,10 +20,14 @@ import {
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { Dispatch, ReactNode, SetStateAction } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { launchLocalEpubSidecar, sidecarApi } from '@/services/localEpubSidecarService'; import { useEnv } from '@/context/EnvContext';
import { isTauriAppPlatform } from '@/services/environment';
import { useLibraryStore } from '@/store/libraryStore';
import { openExternalUrl } from '@/utils/open'; import { openExternalUrl } from '@/utils/open';
type LaunchState = 'idle' | 'launching' | 'ready' | 'failed'; type LaunchState = 'idle' | 'launching' | 'ready' | 'failed';
type LaunchMode = 'review' | 'translate';
type LaunchResponse = { type LaunchResponse = {
ok: boolean; ok: boolean;
url?: string; url?: string;
@@ -33,7 +39,9 @@ type LaunchResponse = {
}; };
type LaunchContext = { type LaunchContext = {
mode?: LaunchMode;
epubPath?: string; epubPath?: string;
bookHash?: string;
sessionId?: string; sessionId?: string;
}; };
@@ -124,6 +132,63 @@ type GptConfig = {
}; };
}; };
type TranslationDefaultsPayload = {
version?: string;
gpt?: GptConfig;
defaults?: {
output_mode?: string;
range_mode?: string;
limit?: number;
temperature?: number;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
glossary_path?: string;
};
prompt_defaults?: GptConfig['prompt_defaults'];
};
type TranslationSourcePayload = {
session: SessionSummary;
series_config?: {
series_id?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
glossary_path?: string;
};
source_count: number;
stats?: Record<string, unknown>;
files?: Array<{ file: string; count: number }>;
sample?: Array<{ id: string; file: string; p_index: number; text: string }>;
};
type TranslationJob = {
id?: string;
status?: string;
output_epub?: string;
output_session_id?: string;
error?: string;
logs?: Array<{ ts?: string; level?: string; message?: string }>;
progress?: {
current?: number;
total?: number;
percent?: number;
failed?: number;
message?: string;
current_file?: string;
current_item_id?: string;
};
settings_summary?: Record<string, unknown>;
};
type TranslationJobPayload = {
status?: string;
job_id?: string;
job?: TranslationJob;
error?: string;
};
type EditState = { type EditState = {
current_html: string; current_html: string;
marked: boolean; marked: boolean;
@@ -142,8 +207,33 @@ type TranslationFormState = {
translation_prompt: string; translation_prompt: string;
format_prompt: string; format_prompt: string;
character_prompt: string; character_prompt: string;
output_mode: 'bilingual' | 'translated';
range_mode: 'limit' | 'all';
limit: number;
temperature: number;
use_series_config: boolean;
}; };
const featureBlocks: Array<{
mode: LaunchMode;
title: string;
description: string;
icon: typeof BookOpenCheck;
}> = [
{
mode: 'review',
title: '校对',
description: '逐段读取、修改译文、记录问题、单段重翻与导出 EPUB。',
icon: BookOpenCheck,
},
{
mode: 'translate',
title: '翻译',
description: '设置 API、提示词、术语表、范围与输出格式,启动整书翻译。',
icon: Languages,
},
];
const emptyEditState: EditState = { const emptyEditState: EditState = {
current_html: '', current_html: '',
marked: false, marked: false,
@@ -162,6 +252,11 @@ const emptyTranslationForm: TranslationFormState = {
translation_prompt: '', translation_prompt: '',
format_prompt: '', format_prompt: '',
character_prompt: '', character_prompt: '',
output_mode: 'bilingual',
range_mode: 'limit',
limit: 20,
temperature: 0.2,
use_series_config: false,
}; };
const cnHtmlPurifyOptions = { const cnHtmlPurifyOptions = {
@@ -248,9 +343,48 @@ const stripHtml = (html: string) => {
return div.textContent || div.innerText || ''; return div.textContent || div.innerText || '';
}; };
const formatPercent = (value: number) =>
`${Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)).toFixed(value % 1 ? 1 : 0)}%`;
const getSessionTitle = (session: SessionSummary | SessionPayload | null) => const getSessionTitle = (session: SessionSummary | SessionPayload | null) =>
session?.title || session?.source_name || session?.id || '未选择 EPUB'; session?.title || session?.source_name || session?.id || '未选择 EPUB';
async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit = {}): Promise<T> {
const url = new URL(path, baseUrl);
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
const headers =
options.body === undefined || isFormData
? options.headers
: {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as T;
}
async function uploadSessionFromFile(baseUrl: string, file: File): Promise<SessionSummary> {
const formData = new FormData();
formData.set('epub', file, file.name || 'book.epub');
return sidecarApi<SessionSummary>(baseUrl, '/api/upload', {
method: 'POST',
body: formData,
});
}
const openSessionBody = (sessionId: string, activate = true) =>
JSON.stringify({ session_id: sessionId, activate });
const sessionFromPathBody = (epubPath: string, activate = true) =>
JSON.stringify({ epub_path: epubPath, activate });
function readLaunchContextFromBrowser(): LaunchContext { function readLaunchContextFromBrowser(): LaunchContext {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -263,9 +397,16 @@ function readLaunchContextFromBrowser(): LaunchContext {
if (params.has('epub_path')) { if (params.has('epub_path')) {
context.epubPath = params.get('epub_path') || undefined; context.epubPath = params.get('epub_path') || undefined;
} }
if (params.has('book_hash')) {
context.bookHash = params.get('book_hash') || undefined;
}
if (params.has('session_id')) { if (params.has('session_id')) {
context.sessionId = params.get('session_id') || undefined; context.sessionId = params.get('session_id') || undefined;
} }
const requestedMode = params.get('mode') || context.mode;
if (requestedMode === 'translate' || requestedMode === 'review') {
context.mode = requestedMode;
}
return context; return context;
} }
@@ -277,11 +418,15 @@ function rowMatchesScope(row: ReviewRow, scope: string) {
return scope === 'all' || row.file === scope || rowGroupTitle(row) === scope; return scope === 'all' || row.file === scope || rowGroupTitle(row) === scope;
} }
function updateUrlSession(router: ReturnType<typeof useRouter>, sessionId?: string | null) { function updateUrlSession(
router: ReturnType<typeof useRouter>,
mode: LaunchMode,
sessionId?: string | null,
) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.delete('epub_path'); params.delete('epub_path');
params.delete('mode'); params.set('mode', mode);
if (sessionId) { if (sessionId) {
params.set('session_id', sessionId); params.set('session_id', sessionId);
} else { } else {
@@ -426,7 +571,15 @@ function GptConfigPanel({
); );
} }
function ProofreadBlock({ baseUrl, sessionId }: { baseUrl: string; sessionId?: string | null }) { function ProofreadBlock({
baseUrl,
sessionId,
onSwitchMode,
}: {
baseUrl: string;
sessionId?: string | null;
onSwitchMode: (mode: LaunchMode) => void;
}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [session, setSession] = useState<SessionPayload | null>(null); const [session, setSession] = useState<SessionPayload | null>(null);
@@ -966,12 +1119,439 @@ function ProofreadBlock({ baseUrl, sessionId }: { baseUrl: string; sessionId?: s
</section> </section>
</> </>
)} )}
<button
type='button'
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md text-sm'
onClick={() => onSwitchMode('translate')}
>
</button>
</div> </div>
</aside> </aside>
</div> </div>
); );
} }
function TranslateBlock({
baseUrl,
sessionId,
onOpenSession,
onSwitchMode,
}: {
baseUrl: string;
sessionId?: string | null;
onOpenSession: (sessionId: string, mode?: LaunchMode) => Promise<void>;
onSwitchMode: (mode: LaunchMode) => void;
}) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
const [form, setForm] = useState<TranslationFormState>(emptyTranslationForm);
const [job, setJob] = useState<TranslationJob | null>(null);
const [starting, setStarting] = useState(false);
const [gptLoaded, setGptLoaded] = useState(false);
const pollTimerRef = useRef<number | null>(null);
const currentJobIdRef = useRef<string | null>(null);
const clearPoll = useCallback(() => {
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const loadTranslationData = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
setMessage('');
setGptLoaded(false);
try {
const [sourcePayload, defaultsPayload] = await Promise.all([
sidecarApi<TranslationSourcePayload>(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
),
sidecarApi<TranslationDefaultsPayload>(baseUrl, '/api/translation/defaults'),
]);
const defaults = defaultsPayload.defaults || {};
const gpt = defaultsPayload.gpt || {};
const seriesConfig = sourcePayload.series_config || {};
setSource(sourcePayload);
setForm({
base_url: gpt.base_url || '',
model: gpt.model || '',
api_key: '',
glossary_path:
seriesConfig.glossary_path || defaults.glossary_path || gpt.glossary_path || '',
translation_prompt:
seriesConfig.translation_prompt ||
defaults.translation_prompt ||
gpt.translation_prompt ||
'',
format_prompt:
seriesConfig.format_prompt || defaults.format_prompt || gpt.format_prompt || '',
character_prompt:
seriesConfig.character_prompt || defaults.character_prompt || gpt.character_prompt || '',
output_mode: defaults.output_mode === 'translated' ? 'translated' : 'bilingual',
range_mode: defaults.range_mode === 'all' ? 'all' : 'limit',
limit: Number(defaults.limit || 20),
temperature: Number(defaults.temperature ?? 0.2),
use_series_config: Boolean(
seriesConfig.translation_prompt ||
seriesConfig.format_prompt ||
seriesConfig.character_prompt ||
seriesConfig.glossary_path,
),
});
setGptLoaded(true);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
setGptLoaded(false);
} finally {
setLoading(false);
}
}, [baseUrl, sessionId]);
useEffect(() => {
void loadTranslationData();
return clearPoll;
}, [clearPoll, loadTranslationData]);
const saveGptConfig = useCallback(async () => {
await sidecarApi<GptConfig>(baseUrl, '/api/gpt/config', {
method: 'POST',
body: JSON.stringify({
base_url: form.base_url || undefined,
model: form.model || undefined,
api_key: form.api_key,
keep_existing: true,
glossary_path: form.glossary_path,
translation_prompt: form.translation_prompt,
format_prompt: form.format_prompt,
character_prompt: form.character_prompt,
}),
});
setForm((current) => ({ ...current, api_key: '' }));
}, [baseUrl, form]);
const saveSeriesConfig = async () => {
const seriesId = source?.session?.series_id || source?.series_config?.series_id;
if (!seriesId) {
setMessage('当前书籍没有可用的系列 ID。');
return;
}
setMessage('');
try {
await sidecarApi(baseUrl, `/api/series/${encodeURIComponent(seriesId)}/config`, {
method: 'POST',
body: JSON.stringify({
glossary_path: form.glossary_path,
translation_prompt: form.translation_prompt,
format_prompt: form.format_prompt,
character_prompt: form.character_prompt,
}),
});
setForm((current) => ({ ...current, use_series_config: true }));
setMessage('已保存为同系列配置。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const pollJob = useCallback(
async (jobId: string) => {
try {
const payload = await sidecarApi<TranslationJobPayload>(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
);
const nextJob = payload.job || null;
if (currentJobIdRef.current !== jobId) return;
setJob(nextJob);
if (!nextJob || ['completed', 'failed', 'cancelled'].includes(nextJob.status || '')) {
clearPoll();
setStarting(false);
return;
}
pollTimerRef.current = window.setTimeout(() => void pollJob(jobId), 1200);
} catch (error) {
clearPoll();
setStarting(false);
setMessage(error instanceof Error ? error.message : String(error));
}
},
[baseUrl, clearPoll],
);
const startTranslation = async () => {
if (!sessionId) return;
if (
form.range_mode === 'all' &&
!window.confirm('全书翻译可能消耗较长时间和较多 API 额度。确认开始吗?')
) {
return;
}
setStarting(true);
setMessage('');
setJob(null);
clearPoll();
currentJobIdRef.current = null;
try {
await saveGptConfig();
const payload = await sidecarApi<TranslationJobPayload>(baseUrl, '/api/translation/start', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
output_mode: form.output_mode,
range_mode: form.range_mode,
limit: form.limit,
temperature: form.temperature,
base_url: form.base_url,
model: form.model,
glossary_path: form.glossary_path,
translation_prompt: form.translation_prompt,
format_prompt: form.format_prompt,
character_prompt: form.character_prompt,
use_series_glossary_path: form.use_series_config,
use_series_translation_prompt: form.use_series_config,
use_series_format_prompt: form.use_series_config,
use_series_character_prompt: form.use_series_config,
}),
});
setJob(payload.job || null);
const jobId = payload.job_id || payload.job?.id || '';
if (jobId) {
currentJobIdRef.current = jobId;
pollTimerRef.current = window.setTimeout(() => void pollJob(jobId), 600);
} else {
setStarting(false);
setMessage('翻译任务已创建,但后端没有返回 job_id。');
}
} catch (error) {
setStarting(false);
setMessage(error instanceof Error ? error.message : String(error));
}
};
const progress = job?.progress || {};
const percent = Number(progress.percent || 0);
const canOpenOutput = Boolean(job?.output_session_id);
if (!sessionId) {
return (
<EmptyFeatureState
title='还没有选择要翻译的 EPUB'
description='请从 Readest 书架的 EPUB 右键菜单进入“用 EPUB 审校器翻译”。'
actionLabel='打开旧版书架/上传页'
onAction={() => openExternalUrl(baseUrl)}
/>
);
}
return (
<div className='min-h-0 flex-1 overflow-auto bg-base-200 p-4'>
<div className='mx-auto grid max-w-5xl gap-4'>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<h2 className='text-lg font-semibold'>{getSessionTitle(source?.session || null)}</h2>
<p className='text-base-content/60 mt-1 text-sm'>
{loading ? '正在读取翻译源……' : `可翻译正文块 ${source?.source_count ?? 0}`}
</p>
<p className='text-base-content/60 mt-1 text-xs'>
20
</p>
</div>
<div className='flex flex-wrap gap-2'>
<ToolbarButton onClick={loadTranslationData} icon={<RefreshCw className='h-4 w-4' />}>
</ToolbarButton>
<ToolbarButton
onClick={() => onSwitchMode('review')}
icon={<BookOpenCheck className='h-4 w-4' />}
>
</ToolbarButton>
</div>
</div>
</section>
{message ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
{message}
</div>
) : null}
<GptConfigPanel
form={form}
setForm={setForm}
onSave={saveGptConfig}
disabled={!gptLoaded}
/>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-4 rounded-md border p-4'>
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<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={form.output_mode}
onChange={(event) =>
setForm((current) => ({
...current,
output_mode: event.target.value === 'translated' ? 'translated' : 'bilingual',
}))
}
>
<option value='bilingual'> EPUB</option>
<option value='translated'> EPUB</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={form.range_mode}
onChange={(event) =>
setForm((current) => ({
...current,
range_mode: event.target.value === 'all' ? 'all' : 'limit',
}))
}
>
<option value='limit'> N </option>
<option value='all'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<input
type='number'
min={1}
max={5000}
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
value={form.limit}
onChange={(event) =>
setForm((current) => ({
...current,
limit: Math.max(1, Number(event.target.value || 1)),
}))
}
disabled={form.range_mode === 'all'}
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
Temperature
<input
type='number'
min={0}
max={1}
step={0.1}
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
value={form.temperature}
onChange={(event) =>
setForm((current) => ({
...current,
temperature: Number(event.target.value || 0.2),
}))
}
/>
</label>
</div>
<label className='flex items-center gap-2 text-sm'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={form.use_series_config}
onChange={(event) =>
setForm((current) => ({ ...current, use_series_config: event.target.checked }))
}
/>
</label>
<div className='flex flex-wrap gap-2'>
<ToolbarButton
onClick={saveSeriesConfig}
icon={<Save className='h-4 w-4' />}
disabled={!source?.session?.series_id}
>
</ToolbarButton>
<ToolbarButton
onClick={startTranslation}
disabled={starting || !source?.source_count || !gptLoaded}
icon={<Rocket className='h-4 w-4' />}
variant='primary'
>
{starting ? '翻译运行中' : '开始翻译'}
</ToolbarButton>
</div>
</section>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
<div className='mb-3 flex items-center justify-between gap-2'>
<h3 className='text-sm font-semibold'></h3>
<span className='text-base-content/60 text-xs'>{job?.status || '尚未开始'}</span>
</div>
<div className='bg-base-200 h-2 overflow-hidden rounded-full'>
<div
className='bg-primary h-full rounded-full'
style={{ width: formatPercent(percent) }}
/>
</div>
<pre className='bg-base-200 text-base-content/80 mt-3 max-h-40 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap'>
{[
progress.message || '等待启动。',
progress.total ? `进度:${progress.current || 0}/${progress.total}` : '',
progress.current_file ? `当前文件:${progress.current_file}` : '',
progress.failed ? `失败正文块:${progress.failed}` : '',
job?.error ? `错误:${job.error}` : '',
]
.filter(Boolean)
.join('\n')}
</pre>
{job?.logs?.length ? (
<div className='mt-3 grid gap-2'>
{job.logs
.slice(-10)
.reverse()
.map((log, index) => (
<div
key={`${log.ts || index}-${log.message || ''}`}
className='border-base-300 rounded-md border p-2 text-xs'
>
<span className='text-base-content/50'>{log.ts}</span>
<p>{log.message}</p>
</div>
))}
</div>
) : null}
{job?.output_epub ? (
<div className='eink-bordered border-base-300 mt-3 rounded-md border p-3 text-sm'>
<strong> EPUB </strong>
<p className='text-base-content/60 mt-1 break-all text-xs'>{job.output_epub}</p>
<ToolbarButton
onClick={() => {
if (job.output_session_id) {
void onOpenSession(job.output_session_id, 'review').catch((error) => {
setMessage(error instanceof Error ? error.message : String(error));
});
}
}}
disabled={!canOpenOutput}
icon={<BookOpenCheck className='h-4 w-4' />}
variant='primary'
>
</ToolbarButton>
</div>
) : null}
</section>
</div>
</div>
);
}
function EmptyFeatureState({ function EmptyFeatureState({
title, title,
description, description,
@@ -999,23 +1579,96 @@ function EmptyFeatureState({
export default function ReviewEditorLauncher() { export default function ReviewEditorLauncher() {
const router = useRouter(); const router = useRouter();
const { appService } = useEnv();
const [state, setState] = useState<LaunchState>('idle'); const [state, setState] = useState<LaunchState>('idle');
const [payload, setPayload] = useState<LaunchResponse | null>(null); const [payload, setPayload] = useState<LaunchResponse | null>(null);
const [activeMode, setActiveMode] = useState<LaunchMode>('review');
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const didAutoLaunchRef = useRef(false); const didAutoLaunchRef = useRef(false);
const openSession = useCallback(
async (nextSessionId: string, mode: LaunchMode = activeMode) => {
const baseUrl = payload?.url;
if (!baseUrl) return;
await sidecarApi<SessionSummary>(baseUrl, '/api/session/open', {
method: 'POST',
body: openSessionBody(nextSessionId),
});
setSessionId(nextSessionId);
setActiveMode(mode);
updateUrlSession(router, mode, nextSessionId);
},
[activeMode, payload?.url, router],
);
const createSessionFromBookHash = useCallback(
async (baseUrl: string, bookHash: string) => {
if (!appService) throw new Error('Readest 服务尚未初始化');
let book = useLibraryStore.getState().getBookByHash(bookHash);
if (!book) {
const library = await appService.loadLibraryBooks();
useLibraryStore.getState().setLibrary(library);
book = library.find((item) => item.hash === bookHash);
}
if (!book) throw new Error('无法在书库中找到当前 EPUB');
if (book.format !== 'EPUB') throw new Error('审校器目前只支持 EPUB 书籍');
const { file } = await appService.loadBookContent(book);
return uploadSessionFromFile(baseUrl, file);
},
[appService],
);
const launch = useCallback(async () => { const launch = useCallback(async () => {
setState('launching'); setState('launching');
setPayload(null); setPayload(null);
try { try {
const context = readLaunchContextFromBrowser(); const context = readLaunchContextFromBrowser();
const data = await launchLocalEpubSidecar(context); const requestedMode = context.mode || 'review';
const nextSessionId = data.sessionId; setActiveMode(requestedMode);
const data = isTauriAppPlatform()
? await invoke<LaunchResponse>('launch_epub_review_editor', {
epubPath: context.epubPath || null,
})
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
'x-readest-review-editor-launch': '1',
},
}).then((response) => response.json() as Promise<LaunchResponse>);
if (!data.ok || !data.url) {
throw new Error(data.error || '审校器启动失败');
}
let nextSessionId = data.sessionId || context.sessionId || null;
if (!nextSessionId && context.epubPath) {
const createdSession = await sidecarApi<SessionSummary>(
data.url,
'/api/session/from-path',
{
method: 'POST',
body: sessionFromPathBody(context.epubPath),
},
);
nextSessionId = createdSession.id || null;
}
if (!nextSessionId && context.bookHash) {
const createdSession = await createSessionFromBookHash(data.url, context.bookHash);
nextSessionId = createdSession.id || null;
}
if (!nextSessionId) {
const activeSession = await sidecarApi<SessionPayload>(data.url, '/api/session');
nextSessionId = activeSession.has_session ? activeSession.id || null : null;
}
if (nextSessionId) {
await sidecarApi<SessionSummary>(data.url, '/api/session/open', {
method: 'POST',
body: openSessionBody(nextSessionId),
});
}
setPayload(data); setPayload(data);
setSessionId(nextSessionId); setSessionId(nextSessionId);
sessionStorage.removeItem('reviewEditorLaunchContext'); sessionStorage.removeItem('reviewEditorLaunchContext');
setState('ready'); setState('ready');
updateUrlSession(router, nextSessionId); updateUrlSession(router, requestedMode, nextSessionId);
} catch (error) { } catch (error) {
setPayload({ setPayload({
ok: false, ok: false,
@@ -1023,13 +1676,20 @@ export default function ReviewEditorLauncher() {
}); });
setState('failed'); setState('failed');
} }
}, [router]); }, [createSessionFromBookHash, router]);
useEffect(() => { useEffect(() => {
if (didAutoLaunchRef.current) return; if (didAutoLaunchRef.current) return;
const context = readLaunchContextFromBrowser();
if (context.bookHash && !appService) return;
didAutoLaunchRef.current = true; didAutoLaunchRef.current = true;
void launch(); void launch();
}, [launch]); }, [appService, launch]);
const switchMode = (mode: LaunchMode) => {
setActiveMode(mode);
updateUrlSession(router, mode, sessionId);
};
const editorUrl = payload?.url || ''; const editorUrl = payload?.url || '';
const frameTitle = payload?.version ? `EPUB 审校器 v${payload.version}` : 'EPUB 审校器'; const frameTitle = payload?.version ? `EPUB 审校器 v${payload.version}` : 'EPUB 审校器';
@@ -1049,7 +1709,9 @@ export default function ReviewEditorLauncher() {
</button> </button>
<div className='min-w-0'> <div className='min-w-0'>
<h1 className='truncate text-base font-semibold'>EPUB </h1> <h1 className='truncate text-base font-semibold'>EPUB </h1>
<p className='text-base-content/60 truncate text-xs'>Readest </p> <p className='text-base-content/60 truncate text-xs'>
Readest
</p>
</div> </div>
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
@@ -1078,7 +1740,45 @@ export default function ReviewEditorLauncher() {
</header> </header>
{state === 'ready' && editorUrl ? ( {state === 'ready' && editorUrl ? (
<ProofreadBlock baseUrl={editorUrl} sessionId={sessionId} /> <>
<section className='bg-base-100 border-base-300 flex flex-shrink-0 gap-2 border-b px-3 py-2 sm:px-5'>
{featureBlocks.map((block) => {
const Icon = block.icon;
const active = activeMode === block.mode;
return (
<button
key={block.mode}
type='button'
className={clsx(
'eink-bordered flex min-w-0 flex-1 items-center gap-3 rounded-md border px-3 py-2 text-start sm:max-w-sm',
active
? 'border-primary bg-primary/10 text-primary'
: 'border-base-300 bg-base-100 hover:bg-base-200',
)}
onClick={() => switchMode(block.mode)}
>
<Icon className='h-5 w-5 flex-shrink-0' />
<span className='min-w-0'>
<span className='block truncate text-sm font-semibold'>{block.title}</span>
<span className='text-base-content/60 hidden truncate text-xs sm:block'>
{block.description}
</span>
</span>
</button>
);
})}
</section>
{activeMode === 'review' ? (
<ProofreadBlock baseUrl={editorUrl} sessionId={sessionId} onSwitchMode={switchMode} />
) : (
<TranslateBlock
baseUrl={editorUrl}
sessionId={sessionId}
onOpenSession={openSession}
onSwitchMode={switchMode}
/>
)}
</>
) : ( ) : (
<section className='flex min-h-0 flex-1 items-center justify-center p-5'> <section className='flex min-h-0 flex-1 items-center justify-center p-5'>
<div className='bg-base-100 eink-bordered w-full max-w-2xl rounded-md p-6 shadow-xl'> <div className='bg-base-100 eink-bordered w-full max-w-2xl rounded-md p-6 shadow-xl'>
@@ -1,32 +0,0 @@
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import {
importLocalEpubFile,
type LocalEpubSessionSummary,
} from '@/services/localEpubSidecarService';
export type LocalEpubBookReference =
| { kind: 'path'; epubPath: string }
| { kind: 'session'; session: LocalEpubSessionSummary };
export async function prepareBookForLocalEpubSidecar(
appService: AppService,
book: Book,
baseUrl: string,
): Promise<LocalEpubBookReference> {
if (appService.appPlatform !== 'web') {
const epubPath = await appService.resolveNativeBookFilePath(book);
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
return { kind: 'path', epubPath };
}
const content = await appService.loadBookContent(book);
const filename = content.file.name || `${book.title || book.hash}.epub`;
const file =
content.file.name === filename
? content.file
: new File([await content.file.arrayBuffer()], filename, {
type: content.file.type || 'application/epub+zip',
});
return { kind: 'session', session: await importLocalEpubFile(baseUrl, file) };
}
@@ -1,153 +0,0 @@
import { sidecarApi } from '@/services/localEpubSidecarService';
export type TranslationChunkPlan = {
id: string;
file: string;
title?: string;
first_item_id: string;
last_item_id: string;
item_count: number;
source_characters: number;
estimated_input_tokens: number;
};
export type TranslationPlanPayload = {
session_id: string;
source_count: number;
chunk_target: number;
chunk_count: number;
estimated_input_tokens: number;
chunks: TranslationChunkPlan[];
};
export type TranslationJobSummary = {
id: string;
status: string;
source_session_id: string;
source_name?: string;
progress?: {
current?: number;
total?: number;
percent?: number;
failed?: number;
message?: string;
};
output_session_id?: string;
error?: string;
logs?: Array<{ ts?: string; level?: string; message?: string }>;
};
export type TranslationLibrary = {
sessions: Array<{ id: string; series_id?: string; series?: string }>;
series: Array<{ id: string; title: string; count: number; books?: string[] }>;
};
export type TranslationTermCandidate = {
source: string;
candidate_translation: string;
status: string;
count: number;
files: string[];
example: string;
};
export type TranslationPreflightPayload = {
session_id: string;
source_count: number;
glossary_path?: string;
glossary_count?: number;
candidates: TranslationTermCandidate[];
candidate_count: number;
confirmed: boolean;
confirmed_at?: string;
};
const updateLibrarySeries = (
baseUrl: string,
payload: Record<string, string>,
): Promise<TranslationLibrary> =>
sidecarApi(baseUrl, '/api/library/series', {
method: 'POST',
body: JSON.stringify(payload),
});
export const createLibrarySeries = (baseUrl: string, title: string) =>
updateLibrarySeries(baseUrl, { action: 'create', title });
export const renameLibrarySeries = (baseUrl: string, seriesId: string, title: string) =>
updateLibrarySeries(baseUrl, { action: 'rename', series_id: seriesId, title });
export const assignBookToSeries = (baseUrl: string, sessionId: string, seriesId: string) =>
updateLibrarySeries(baseUrl, {
action: 'assign',
session_id: sessionId,
series_id: seriesId,
});
export const deleteLibrarySeries = (baseUrl: string, seriesId: string) =>
updateLibrarySeries(baseUrl, { action: 'delete', series_id: seriesId });
export function loadTranslationPreflight(
baseUrl: string,
sessionId: string,
glossaryPath: string,
): Promise<TranslationPreflightPayload> {
const suffix = glossaryPath ? `?glossary_path=${encodeURIComponent(glossaryPath)}` : '';
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-preflight${suffix}`,
);
}
export function confirmTranslationPreflight(
baseUrl: string,
sessionId: string,
glossaryPath: string,
): Promise<TranslationPreflightPayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-preflight`,
{
method: 'POST',
body: JSON.stringify({ glossary_path: glossaryPath, confirmed: true }),
},
);
}
export async function loadTranslationPlan(
baseUrl: string,
sessionId: string,
chunkTarget: number,
): Promise<TranslationPlanPayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-plan?chunk_target=${encodeURIComponent(String(chunkTarget))}`,
);
}
export async function listTranslationJobs(
baseUrl: string,
sessionId?: string,
): Promise<{ jobs: TranslationJobSummary[]; count: number }> {
const suffix = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : '';
return sidecarApi(baseUrl, `/api/translation/jobs${suffix}`);
}
export async function controlTranslationJob(
baseUrl: string,
jobId: string,
action: 'pause' | 'resume' | 'cancel',
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(baseUrl, `/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`, {
method: 'POST',
body: '{}',
});
}
export async function probeTranslationModels(baseUrl: string): Promise<{
models: string[];
configured_model: string;
configured_model_available: boolean;
}> {
return sidecarApi(baseUrl, '/api/gpt/models');
}
@@ -1,121 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { isTauriAppPlatform } from '@/services/environment';
export type LocalEpubSidecarLaunchResponse = {
ok: boolean;
url?: string;
reused?: boolean;
reviewRoot?: string;
version?: string;
sessionId?: string | null;
error?: string;
};
export type LocalEpubSessionSummary = {
id: string;
source_name?: string;
title?: string;
source_epub?: string;
series_id?: string;
series?: string;
};
export type LocalEpubLaunchContext = {
epubPath?: string;
sessionId?: string;
};
export async function importLocalEpubFile(
baseUrl: string,
file: File,
): Promise<LocalEpubSessionSummary> {
const url = new URL('/api/upload', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
const form = new FormData();
form.append('epub', file, file.name);
const response = await fetch(url.toString(), { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(String(data.error || `HTTP ${response.status}`));
return data as LocalEpubSessionSummary;
}
export async function launchLocalEpubSidecar(
context: LocalEpubLaunchContext,
): Promise<LocalEpubSidecarLaunchResponse & { url: string; sessionId: string | null }> {
const launch = isTauriAppPlatform()
? await invoke<LocalEpubSidecarLaunchResponse>('launch_epub_review_editor', {
epubPath: context.epubPath || null,
})
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: { 'x-readest-review-editor-launch': '1' },
}).then((response) => response.json() as Promise<LocalEpubSidecarLaunchResponse>);
if (!launch.ok || !launch.url) {
throw new Error(launch.error || '本地 EPUB 服务启动失败');
}
let sessionId = launch.sessionId || context.sessionId || null;
if (!sessionId && context.epubPath) {
const created = await sidecarApi<LocalEpubSessionSummary>(
launch.url,
'/api/session/from-path',
{
method: 'POST',
body: JSON.stringify({ epub_path: context.epubPath, activate: true }),
},
);
sessionId = created.id || null;
}
if (!sessionId) {
const active = await sidecarApi<{ has_session?: boolean; id?: string }>(
launch.url,
'/api/session',
);
sessionId = active.has_session ? active.id || null : null;
}
if (sessionId) {
await sidecarApi(launch.url, '/api/session/open', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, activate: true }),
});
}
return { ...launch, url: launch.url, sessionId };
}
export async function ensureLocalEpubSidecar(): Promise<
LocalEpubSidecarLaunchResponse & { url: string }
> {
const launch = isTauriAppPlatform()
? await invoke<LocalEpubSidecarLaunchResponse>('launch_epub_review_editor', {
epubPath: null,
})
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: { 'x-readest-review-editor-launch': '1' },
}).then((response) => response.json() as Promise<LocalEpubSidecarLaunchResponse>);
if (!launch.ok || !launch.url) {
throw new Error(launch.error || '本地 EPUB 服务启动失败');
}
return { ...launch, url: launch.url };
}
export async function sidecarApi<T>(
baseUrl: string,
path: string,
options: RequestInit = {},
sessionId?: string | null,
): Promise<T> {
if (!baseUrl) throw new Error('本地 EPUB 服务尚未启动');
const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
if (sessionId) url.searchParams.set('session_id', sessionId);
const headers =
options.body === undefined
? options.headers
: { 'Content-Type': 'application/json', ...(options.headers || {}) };
const response = await fetch(url.toString(), { ...options, headers });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(String(data.error || `HTTP ${response.status}`));
return data as T;
}
@@ -3,9 +3,6 @@ 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 } from '@/services/environment';
import { sidecarApi } from '@/services/localEpubSidecarService';
export { sidecarApi } from '@/services/localEpubSidecarService';
export type ReviewEditorLaunchResponse = { export type ReviewEditorLaunchResponse = {
ok: boolean; ok: boolean;
@@ -136,6 +133,40 @@ export type ReviewSessionFromPathOptions = {
reset?: boolean; reset?: boolean;
}; };
const ensureBaseUrl = (baseUrl: string) => {
if (!baseUrl) throw new Error('审校器后端尚未启动');
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
};
export async function sidecarApi<T>(
baseUrl: string,
path: string,
options: RequestInit = {},
sessionId?: string | null,
): Promise<T> {
const url = new URL(path, ensureBaseUrl(baseUrl));
if (sessionId) {
url.searchParams.set('session_id', sessionId);
}
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
const headers =
options.body === undefined || isFormData
? options.headers
: {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as T;
}
export async function createReviewSessionFromPath( export async function createReviewSessionFromPath(
baseUrl: string, baseUrl: string,
epubPath: string, epubPath: string,
@@ -151,6 +182,23 @@ export async function createReviewSessionFromPath(
}); });
} }
export async function uploadReviewSession(
baseUrl: string,
file: File,
options: { reset?: boolean } = {},
): Promise<ReviewSessionSummary> {
const formData = new FormData();
formData.set('epub', file, file.name || 'book.epub');
if (options.reset) {
formData.set('reset', '1');
}
return sidecarApi(baseUrl, '/api/upload', {
method: 'POST',
body: formData,
});
}
export async function openReviewSession( export async function openReviewSession(
baseUrl: string, baseUrl: string,
sessionId: string, sessionId: string,
@@ -172,10 +220,12 @@ export async function launchInlineReviewEditor(
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 = isTauriAppPlatform() ? await appService.resolveNativeBookFilePath(book) : null;
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径'); if (isTauriAppPlatform() && !epubPath) {
throw new Error('无法定位当前 EPUB 的本机文件路径');
}
const launch = isTauriAppPlatform() const launch = epubPath
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { ? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
epubPath, epubPath,
}) })
@@ -192,7 +242,9 @@ export async function launchInlineReviewEditor(
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)
: await uploadReviewSession(launch.url, (await appService.loadBookContent(book)).file);
sessionId = createdSession.id || null; sessionId = createdSession.id || null;
} }
-8
View File
@@ -140,14 +140,6 @@ export const navigateToReviewEditor = (
router.push(`/review-editor${query ? `?${query}` : ''}`); router.push(`/review-editor${query ? `?${query}` : ''}`);
}; };
export const navigateToFullBookTranslation = (
router: ReturnType<typeof useRouter>,
queryParams?: string | URLSearchParams,
) => {
const query = queryParams?.toString() || '';
router.push(`/full-book-translation${query ? `?${query}` : ''}`);
};
// Recovery action when a reader has nothing to display — e.g. all books were // Recovery action when a reader has nothing to display — e.g. all books were
// closed, or a book failed to load in a freshly-opened reader window. // closed, or a book failed to load in a freshly-opened reader window.
// In a dedicated reader window we close the window itself, ensuring the main // In a dedicated reader window we close the window itself, ensuring the main
@@ -2,11 +2,9 @@
## 目标与边界 ## 目标与边界
本工具的审校产品只服务于中日双语 EPUB:长篇连续阅读、逐段修改中文译文、标记翻译问题、GPT 单段重翻、导出反馈与修订 EPUB。Readest `/review-editor` 只承载这些审校能力,不得提供全书翻译模式、翻译页签或全书任务入口 本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束
正式全书翻译是 Readest 独立模块 `/full-book-translation`,以系列、候选术语确认、chunk 计划和持久任务为中心。它与审校器共享本目录下的 Python/Flask sidecar、会话目录、GPT 配置、EPUB 解析与任务 API,只是复用基础设施,不表示两者属于同一 UI 或工作阶段。翻译质量规则由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md``mingcibiao.json` 共同约束。用户在审校页留下的编辑、问题备注长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。 AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
Readest Web 的书籍内容位于浏览器 IndexedDB,`Readest/Books/...` 是逻辑键而非本机路径。Web 入口必须读取 `BookContent.file` 并以 multipart 调用 `/api/upload`,随后只传 `session_id`;不得把 `resolveNativeBookFilePath()` 返回的 Web 逻辑键交给 `/api/session/from-path`。Tauri 桌面端才使用真实文件路径。
## 数据模型与会话目录 ## 数据模型与会话目录
@@ -52,7 +50,7 @@ epub_review_sessions/
- `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。 - `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。 - `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- Readest 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 sessionReadest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。 - Readest 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 sessionReadest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
- Readest `/review-editor` 的主路径必须是原生 React 审校功能块,直接调用本地 sidecar 的审校 API;全书翻译必须位于独立 `/full-book-translation` 模块。`static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面,其历史翻译入口也不得重新暴露为 Readest 审校功能。若 React 直接访问 loopback sidecarsidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。 - Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API`static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecarsidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。中文编辑区的译文预览默认折叠;注音、注释和保存按钮应靠近“修改中文译文”标题,注音/注释操作应对当前选择位置插入可直接改写的内联标签并自动展开预览,便于保存前确认格式。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。固定状态、宽度与浮动高度可作为本地布局偏好持久化,但不得把当前书审校行、session 数据或 API Key 写入该偏好。浮动模式拖动和宽高调整应使用统一的 pointer 事件逻辑,窗口尺寸或移动/桌面断点变化时要重新夹取到可视区域内。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。 - Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。中文编辑区的译文预览默认折叠;注音、注释和保存按钮应靠近“修改中文译文”标题,注音/注释操作应对当前选择位置插入可直接改写的内联标签并自动展开预览,便于保存前确认格式。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。固定状态、宽度与浮动高度可作为本地布局偏好持久化,但不得把当前书审校行、session 数据或 API Key 写入该偏好。浮动模式拖动和宽高调整应使用统一的 pointer 事件逻辑,窗口尺寸或移动/桌面断点变化时要重新夹取到可视区域内。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
## 目录与插图 ## 目录与插图
@@ -175,18 +173,9 @@ Prompt 硬规则:
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。 - 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。 - 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
## 独立全书翻译工作台 ## 整书 AI 翻译
全书翻译从 Readest 书架的“全书翻译”动作或 `/full-book-translation` 进入,不得通过 `/review-editor?mode=translate`、审校页签或审校面板进入。工作台只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为持久后台任务运行,前端负责系列/书籍管理、候选术语确认、chunk 预览、创建与控制任务、展示日志,以及把完成的双语 EPUB 转入审校 整书 AI 翻译从书架页进入,只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为后台任务运行,前端负责创建任务、轮询进度、展示日志和打开输出 session
产品边界:
- `/review-editor` 只校对;`/full-book-translation` 只管理正式全书翻译。完成任务后允许从翻译工作台单向进入输出书的审校页。
- 两个模块可共享 sidecar URL、`--review-root`、session、GPT 配置和底层 API client,但不得共享页面 mode 状态或把本机 EPUB 路径长期放在 URL 中。
- 全书翻译以人工系列归类为准,EPUB 元数据只提供初始建议;系列必须支持创建、改名、书籍重新归类,以及删除没有关联书籍的空系列。
- 正式术语表继续使用系列 `mingcibiao.json` 对象格式。当前自动候选扫描以 EPUB 中尚未命中正式词表的 ruby 组合为核心,不宣称覆盖所有人名、地名或高频专名;候选不得自动写入正式词表。用户应先在正式术语表中完成需要的增删改,再显式确认当前候选扫描,正式任务才能启动。
- 正式流程固定为“导入 EPUB → 绑定系列 → 候选术语确认/锁词 → chunk 预览 → 首译 → 独立复审 → 一致性 QA → 中日双语 EPUB”。默认高质量模式;同一本书按 chunk 顺序执行,不同书籍可并行。
- 任务启动时保存模型、Base URL、分层提示词、术语表路径、质量模式与 chunk 目标等设置快照,并将正式术语表内容写入任务目录的 `frozen_glossary.json`。源 EPUB、冻结设置和冻结术语表共同生成输入 fingerprint;运行中修改系列、全局配置或正式术语表只影响后续任务。
后端接口口径: 后端接口口径:
@@ -196,29 +185,23 @@ Prompt 硬规则:
- `GET/POST /api/series/<series_id>/config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。 - `GET/POST /api/series/<series_id>/config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。
- `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url``model` 后复用已保存密钥,要更换端点或模型必须先保存配置。 - `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url``model` 后复用已保存密钥,要更换端点或模型必须先保存配置。
- `GET /api/translation/jobs/<job_id>` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。 - `GET /api/translation/jobs/<job_id>` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。
- `GET /api/translation/jobs` 返回所有书籍的公开任务摘要,支持 `session_id` 过滤;用于 Readest 多书任务中心。
- `POST /api/library/series` 使用 `create|rename|assign|delete` 动作管理手动系列和书籍归类;人工归类写入 `library_overrides.json`,书架重建不得用 EPUB 元数据覆盖。
- `/api/library``/api/sessions` 为每本书返回 `translation_status=untranslated|translating|translated` 和最近公开任务摘要;状态由持久任务派生,不使用浏览器内存判断。
- `GET/POST /api/session/<session_id>/translation-plan` 按 XHTML 文件边界与目标正文块数返回 chunk 预览和 token 粗略估算,不启动翻译。
- `POST /api/translation/jobs/<job_id>/pause|resume|cancel` 控制可靠任务;暂停和取消在当前 API 调用结束后生效,恢复复用逐正文块原子 checkpoint。
- `GET /api/gpt/models` 从已保存的 OpenAI 兼容端点读取模型列表,只返回模型 ID;当前全书翻译默认模型为 `gpt-5.6-sol`
翻译输入与提示词: 翻译输入与提示词:
- 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()` - 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`
- 翻译源抽取器至少覆盖 `<p>` 与正文 `<h1-h6>`;日文上下文中的纯汉字/标点短句应进入翻译队列,纯图片、空白和目录页不进入。 - 翻译源抽取器至少覆盖 `<p>` 与正文 `<h1-h6>`;日文上下文中的纯汉字/标点短句应进入翻译队列,纯图片、空白和目录页不进入。
- 正式全书翻译必须在候选扫描得到用户显式确认后启动;当前独立工作台不把“前 20 段试译”描述为已完成的正式流程 - 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择
- 一个 chunk 必须包含多个连续正文段落,一次模型请求处理一个完整 chunk,并以稳定 block ID 返回全部对应译文;不得再按单段逐次请求。请求只携带当前 chunk 原文 HTML、必要的相邻上下文、chunk 内实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。 - 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。
- 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。 - 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。
- 若同系列配置存在,整书翻译优先使用同系列的术语表路径和提示词;API Key、Base URL、模型仍使用全局 API 配置。 - 若同系列配置存在,整书翻译优先使用同系列的术语表路径和提示词;API Key、Base URL、模型仍使用全局 API 配置。
- 输出只接受当前 chunk 内按 block ID 对齐的中文 HTML 片段,随后逐块进行中文标点规整、HTML sanitize术语 ruby 修复与覆盖率校验 - 输出只接受当前中文 HTML 片段,随后进行中文标点规整、HTML sanitize术语 ruby 括号形式修复。
输出与书架: 输出与书架:
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。 - `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。
- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。 - 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。
- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。 - 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。
- 正式任务核心产物只生成 `bilingual` 中日双语 EPUB,保证可以进入 `/review-editor` 逐段审校。纯中文版应从完整双语成果派生,不作为首阶段正式任务输出 - `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行
- 图片、样式、OPF、spine 和非正文文件应原样保留。 - 图片、样式、OPF、spine 和非正文文件应原样保留。
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。 - 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
- 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。 - 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。
@@ -229,7 +212,7 @@ Prompt 硬规则:
- `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。 - `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。
- 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。 - 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。
- 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。 - 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。
- 暂停取消恢复必须保持现有 `job.json` 字段向后兼容。恢复只能复用输入 fingerprint 一致的 checkpoint;源 EPUB、冻结设置或冻结术语表不一致时必须拒绝恢复并要求新建任务。 - 后续如加入暂停/取消/恢复必须保持现有 `job.json` 字段向后兼容。
## 版本规则 ## 版本规则
@@ -245,7 +228,6 @@ Prompt 硬规则:
- `version.py` - `version.py`
- `static/index.html` 顶部版本徽标 fallback - `static/index.html` 顶部版本徽标 fallback
- `static/index.html``app.js` / `style.css` 缓存参数
- `README.md` 当前版本与版本说明 - `README.md` 当前版本与版本说明
- 必要时同步 `MAINTENANCE.md` 中的维护口径 - 必要时同步 `MAINTENANCE.md` 中的维护口径
@@ -263,14 +245,13 @@ Prompt 硬规则:
- `node --check tools/epub-review-editor/static/app.js` - `node --check tools/epub-review-editor/static/app.js`
- `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页 - `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页
- Readest 桌面端 `invoke('launch_epub_review_editor')` 能启动或复用 sidecar,成功时返回 sidecar URL、版本、review root 和可选 session iddev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用 - Readest 桌面端 `invoke('launch_epub_review_editor')` 能启动或复用 sidecar,成功时返回 sidecar URL、版本、review root 和可选 session iddev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用
- Readest `/review-editor` 页面只能提供原生 React 校对功能块,不显示“翻译”页签或 `mode=translate`;旧网页只能作为外部打开/调试 fallback - Readest `/review-editor` 页面必须提供“校对”“翻译”两个原生 React 功能块,切换后直接调用 sidecar API,不得把旧静态审校器 iframe 作为主工作区;旧网页只能作为外部打开/调试 fallback
- Readest `/full-book-translation` 必须是独立的系列/任务工作台,直接调用共享 sidecar API,不得嵌入 `/review-editor` 或依赖其页面 mode
- Readest 原生阅读页顶栏“校”按钮必须能启动/复用 sidecar,当前 EPUB 的中日双语段落必须能被 Foliate iframe 内高亮,并能通过点击或文本选择打开右侧审校面板;关闭审校模式后高亮、事件监听与临时译文预览必须清理 - Readest 原生阅读页顶栏“校”按钮必须能启动/复用 sidecar,当前 EPUB 的中日双语段落必须能被 Foliate iframe 内高亮,并能通过点击或文本选择打开右侧审校面板;关闭审校模式后高亮、事件监听与临时译文预览必须清理
- Readest 原生阅读页右侧审校面板必须支持保存 `/api/row/<row_id>`、重翻 `/api/row/<row_id>/retranslate`、配置 `/api/gpt/config`、读取/保存 `/api/glossary`、生成 `/api/feedback` 与导出 `/api/export` - Readest 原生阅读页右侧审校面板必须支持保存 `/api/row/<row_id>`、重翻 `/api/row/<row_id>/retranslate`、配置 `/api/gpt/config`、读取/保存 `/api/glossary`、生成 `/api/feedback` 与导出 `/api/export`
- 校对功能块至少能加载 `/api/rows``/api/structure`,选择行,保存 `/api/row/<row_id>`,调用 `/api/row/<row_id>/retranslate`,生成反馈 `/api/feedback`,并导出 `/api/export` - 校对功能块至少能加载 `/api/rows``/api/structure`,选择行,保存 `/api/row/<row_id>`,调用 `/api/row/<row_id>/retranslate`,生成反馈 `/api/feedback`,并导出 `/api/export`
- 全书翻译工作台至少能读取 `/api/session/<session_id>/translation-source``/api/translation/defaults`、系列和任务列表,保存系列配置,预览 chunk,启动/暂停/恢复/取消任务,完成后打开输出 session`/review-editor` - 翻译功能块至少能读取 `/api/session/<session_id>/translation-source``/api/translation/defaults`,保存 `/api/gpt/config`,保存同系列 `/api/series/<series_id>/config`,启动 `/api/translation/start`,轮询 `/api/translation/jobs/<job_id>`,完成后打开输出 session
- Readest 书架 EPUB 右键进入校对或全书翻译时,Tauri command 应先创建或复用对应 session;校对跳转 `/review-editor?session_id=...`,翻译跳转 `/full-book-translation?session_id=...`不把本机 EPUB 路径长期留在页面 URL 中 - Readest 书架 EPUB 右键进入校对/翻译时,Tauri command 应先创建或复用对应 session,并将 `/review-editor` 地址整理为 `session_id`,不把本机 EPUB 路径长期留在页面 URL 中
- 审校器静态页只要求兼容 `mode=review&session_id=<id>` 与独立启动场景;历史 `mode=translate` 不得成为 Readest 正式入口 - 审校器静态页支持 `mode=review|translate&session_id=<id>` 直达已有会话,支持 `epub_path=<path>` 先创建/复用会话再进入对应功能
- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口 - 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口
- `git diff --check` - `git diff --check`
- `/api/version` - `/api/version`
@@ -294,19 +275,19 @@ Prompt 硬规则:
- 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css - 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css
- “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转 - “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转
- 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置 - 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置
- Readest 书架菜单的“校对”进入 `/review-editor`,“全书翻译”进入 `/full-book-translation`移动端不依赖 hover 才能操作 - 书架左侧分类可按全部书籍、同系列、作者、出版社、语言筛选;右侧封面网格在桌面端悬停变暗显示信息和“校对/翻译”,移动端不依赖 hover 才能操作
- `GET /api/sessions``GET /api/library` 返回 `library``series`、书籍元数据、`series_id``cover_url`,并写入可重建的 `library.json` - `GET /api/sessions``GET /api/library` 返回 `library``series`、书籍元数据、`series_id``cover_url`,并写入可重建的 `library.json`
- `GET/POST /api/series/<series_id>/config` 能读写同系列术语表和提示词;保存后同系列其他书进入全书翻译工作台会自动套用 - `GET/POST /api/series/<series_id>/config` 能读写同系列术语表和提示词;保存后同系列其他书进入翻译页会自动套用
- 书架卡片“全书翻译”按钮能进入 `/full-book-translation`,且不会触发“进入审校”`/review-editor` 内没有返回全书翻译的模式切换 - 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取 - `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数” - `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
- 全书翻译工作台显示系列、书籍状态、候选术语锁定状态、chunk 计划和持久任务;诊断字段可保留在 API 中供排查,不与正式阶段状态混用 - 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻 - 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志 - 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
- 候选术语扫描当前以 EPUB ruby 为核心;用户完成正式术语表调整后必须显式确认当前扫描,未确认时 `/api/translation/start` 拒绝启动 - 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
- 整书翻译一次请求一个包含多个段落的 chunk,只注入该 chunk 源文/ruby 命中的术语,不注入无关术语或全表;响应按稳定 block ID 完整覆盖该 chunk - 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0` - 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文 - 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文
- 正式任务只生成可逐段审校的中日双语 EPUB;纯中文产物从完整双语成果另行派生 - 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
- 离开全书翻译工作台、返回书架或打开输出审校时,前端轮询会停止,但后台持久任务继续运行 - 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件 - 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件
@@ -2,19 +2,18 @@
## 当前定位 ## 当前定位
本目录是从长期翻译项目迁移进 Readest 的本地 EPUB sidecar 基座。Python/Flask 后端继续提供共享的会话、GPT 配置、EPUB 解析、审校与翻译任务 API;Readest 将产品 UI 拆为两个原生 React 模块:`/review-editor` 只负责审校,`/full-book-translation` 负责系列与全书翻译任务 本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的桌面端基座。当前保留原 Python/Flask 后端作为本地 sidecarReadest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中用原生 React 功能块承载“校对”和“翻译”核心流程
## 入口 ## 入口
- Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器` - Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器`
- 审校路由:`/review-editor` - Next 路由:`/review-editor`
- 全书翻译路由:`/full-book-translation`
- 桌面启动命令:`launch_epub_review_editor` - 桌面启动命令:`launch_epub_review_editor`
- dev-web 启动 API`POST /api/review-editor/launch` - dev-web 启动 API`POST /api/review-editor/launch`
- 开发脚本:`pnpm epub-reviewer:dev` - 开发脚本:`pnpm epub-reviewer:dev`
Readest 桌面端通过 Tauri command 启动本地 sidecar;本地 `dev-web` 继续保留 Next API route 作为开发 fallback。 Readest 桌面端通过 Tauri command 启动本地 sidecar;本地 `dev-web` 继续保留 Next API route 作为开发 fallback。
从 Readest 书架进入单本 EPUB 时,桌面端先把本机路径交给 Tauri command 创建或复用 session,再用 `session_id` 打开对应路由;“校对”进入 `/review-editor`,“全书翻译”进入 `/full-book-translation`页面 URL 不长期保留本机 EPUB 路径。 从 Readest 书架进入单本 EPUB 时,桌面端先把本机路径交给 Tauri command 创建或复用审校 session,再用 `session_id` 打开校对/翻译功能块;页面 URL 不长期保留本机 EPUB 路径。
Readest dev-web 页面带跨源隔离头;本地 sidecar 会为 HTML 入口返回限定本机 Readest 来源的 `frame-ancestors`,并发送 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin`,旧静态网页 fallback 因此仍可被外部打开。React 功能块直接访问 loopback APIsidecar 只对本机 Readest/Tauri 来源返回受限 CORS。 Readest dev-web 页面带跨源隔离头;本地 sidecar 会为 HTML 入口返回限定本机 Readest 来源的 `frame-ancestors`,并发送 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin`,旧静态网页 fallback 因此仍可被外部打开。React 功能块直接访问 loopback APIsidecar 只对本机 Readest/Tauri 来源返回受限 CORS。
@@ -32,30 +31,28 @@ apps/readest-app/epub_review_sessions/
READEST_REVIEW_ROOT=<absolute path> READEST_REVIEW_ROOT=<absolute path>
``` ```
该目录包含书架索引、GPT 配置、翻译任务、审校 session、反馈和导出文件,必须保持在 git 之外。共享数据目录和 sidecar 只属于基础设施复用,不代表审校与全书翻译共享同一个页面或生命周期。 该目录包含书架索引、GPT 配置、翻译任务、审校 session、反馈和导出文件,必须保持在 git 之外。
## 迁移边界 ## 迁移边界
当前阶段不立即把 Flask API 拆成 Next API 或 Tauri command。共享 sidecar 保留已验证的上传、会话、双语审校、术语表、GPT 单段重翻、翻译任务和导出能力;Readest 前端则严格拆开两个产品模块 当前阶段不重写审校器业务逻辑,不把 Flask API 立即拆成 Next API 或 Tauri command。这样可以先保留已验证的功能闭环:书架、上传、双语审校、术语表、GPT 重翻、整书 AI 翻译和导出
`static/index.html` + `static/app.js` 继续保留,用于独立启动、调试和应急 fallback;Readest 桌面迁移验收以 React 功能块为准,不再以 iframe 中显示旧 UI 为完成标准。 `static/index.html` + `static/app.js` 继续保留,用于独立启动、调试和应急 fallback;Readest 桌面迁移验收以 React 功能块为准,不再以 iframe 中显示旧 UI 为完成标准。
后续原生化顺序建议: 后续原生化顺序建议:
1. 保持 `/review-editor` 只做审校,完善与原生阅读页的逐段校对闭环 1. 继续收敛校对/翻译功能块,把术语表编辑、书架分类和阅读位置恢复等长尾操作逐步改成 Readest 原生 React 控件
2. 继续增强 `/full-book-translation` 的系列管理和任务诊断;当前版本已经提供 ruby 候选确认、chunk 请求、独立复审、一致性 QA、术语表内容快照,以及输入 fingerprint 的恢复一致性校验 2. 把 Python/Flask sidecar 打包成独立可执行 sidecar,减少对用户本机 Python 的依赖
3. 把 Python/Flask sidecar 打包成独立可执行 sidecar,减少对用户本机 Python 的依赖 3. 逐步把书架/审校/翻译 UI 拆成 React 组件,最后再替换 Flask API
4. 最后再按明确边界逐步替换 Flask API,不把两个前端模块重新合并。
## 验收标准 ## 验收标准
- 桌面端与本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。 - 桌面端与本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
- `/review-editor` 提供原生 React 校对功能块,不显示全书翻译页签或 `mode=translate`主工作区不渲染旧静态审校器 iframe。 - `/review-editor` 提供“校对”“翻译”两个原生 React 功能块,主工作区不渲染旧静态审校器 iframe。
- `/full-book-translation` 是独立的原生 React 系列/任务工作台,不嵌在审校器里 - Readest EPUB 书籍右键菜单能直接进入审校器校对或翻译
- Readest EPUB 书籍右键菜单能分别进入 `/review-editor` 校对或 `/full-book-translation` 全书翻译。
- 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser` - 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser`
- 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。 - 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。
- 校对功能块能加载行、保存译文/标记、单段重翻、生成反馈并导出 EPUB。 - 校对功能块能加载行、保存译文/标记、单段重翻、生成反馈并导出 EPUB。
- 全书翻译工作台能创建、改名和删除空系列,重新归类书籍,检查并确认以 EPUB ruby 为核心的候选术语,预览 chunk,保存模型与分层提示词等任务设置,冻结正式术语表内容,启动/控制持久任务,并把完成的双语输出打开到 `/review-editor`。候选确认不自动写入正式 `mingcibiao.json`;恢复任务会校验源 EPUB、冻结设置和冻结术语表组成的输入 fingerprint - 翻译功能块能读取可翻译正文块、保存 API/提示词配置、启动任务、轮询进度,并打开输出 session
- 审校数据目录不会进入 git。 - 审校数据目录不会进入 git。
- 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。 - 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器 # 双语 EPUB 审校编辑器
当前版本:`0.18.1` 当前版本:`0.16.5`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -69,12 +69,6 @@
`0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。 `0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。
`0.17.0` 启动 Readest 全书翻译工作台的可靠任务基座:默认模型更新为 `gpt-5.6-sol`,新增模型列表探测、分块计划预览、全局任务列表、暂停/恢复/取消和逐正文块原子断点保存。术语表继续使用 `mingcibiao.json` 的 JSON 对象格式,任务日志与公开 API 不返回密钥。
`0.18.0` 将全书翻译从 EPUB 审校器中正式拆出:Readest `/review-editor` 只保留双语 EPUB 校对、单段重翻、反馈与导出,独立 `/full-book-translation` 以系列、chunk 计划和持久任务为中心承载正式全书翻译。两条路由继续共享本地 Flask sidecar、会话目录、API 配置与 EPUB 解析能力,但不再共享产品入口或页面状态;翻译完成的中日双语 EPUB 可从工作台转入审校器。
`0.18.1` 修复 Readest `dev-web` 上传书籍后进入全书翻译或审校时报 `EPUB not found`:浏览器版书籍存放在 IndexedDB,`Readest/Books/...` 只是逻辑键,不是 Windows 文件路径。Web 端现在先读取实际 EPUB `File`,通过 sidecar `/api/upload` 写入当前 review root,再用返回的 `session_id` 打开;桌面端仍使用真实本机路径。
## 独立安装 ## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -99,18 +93,12 @@ Readest 迁移阶段的推荐入口:
pnpm dev-web pnpm dev-web
``` ```
审校已完成翻译的双语 EPUB 时打开: 然后打开:
```text ```text
http://127.0.0.1:3000/review-editor http://127.0.0.1:3000/review-editor
``` ```
开始或管理全书翻译时打开独立工作台:
```text
http://127.0.0.1:3000/full-book-translation
```
也可以在 Readest 书架页菜单中进入: 也可以在 Readest 书架页菜单中进入:
```text ```text
@@ -123,7 +111,7 @@ Advanced Settings -> EPUB 审校器
apps/readest-app\epub_review_sessions\ apps/readest-app\epub_review_sessions\
``` ```
桌面端会通过 Tauri command 启动本地 sidecardev-web 仍保留 `/api/review-editor/launch` 作为开发入口。Readest `/review-editor` 是原生 React 审校页,`/full-book-translation` 是原生 React 全书翻译工作台;二者直接调用同一个 sidecar API旧静态网页界面仅作为独立运行和排查问题时的兼容 fallback,其中历史翻译入口不属于 Readest 的正式产品入口 桌面端会通过 Tauri command 启动本地 sidecardev-web 仍保留 `/api/review-editor/launch` 作为开发入口。Readest `/review-editor` 页面使用 React 原生“校对”“翻译”功能块直接调用 sidecar API旧静态网页界面仅作为独立运行和排查问题时的 fallback。
在 Readest 桌面端原生阅读界面中,打开 EPUB 后也可以点击顶栏“校”按钮进入内嵌审校模式。该模式不离开阅读页,只给当前书的双语段落加审校标记,并把旧审校器右侧栏能力收敛到阅读页右侧面板。 在 Readest 桌面端原生阅读界面中,打开 EPUB 后也可以点击顶栏“校”按钮进入内嵌审校模式。该模式不离开阅读页,只给当前书的双语段落加审校标记,并把旧审校器右侧栏能力收敛到阅读页右侧面板。
@@ -171,7 +159,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。 - 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。 - 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。 - 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
- Readest 书架中的“校对”进入 `/review-editor`,“全书翻译”进入 `/full-book-translation`;两个动作不得通过审校器内部模式切换互相跳转。“删除”只会从书架隐藏审校会话,不删除原始 EPUB。 - 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。 - 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
## 能做什么 ## 能做什么
@@ -191,7 +179,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 - 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url``model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 - 可从“审校工具”统一配置 OpenAI 兼容的 `base_url``model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。 - 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
- 可从 Readest 书架进入独立“全书翻译”工作台,按系列管理书籍、检查以 EPUB ruby 为核心的候选术语、显式确认当前术语准备状态、预览 chunk,并按已保存的模型与分层提示词设置运行高质量翻译;候选扫描不宣称覆盖所有专名,也不会自动改写 `mingcibiao.json`任务在后台持久运行,页面显示进度与日志,正式结果为可继续审校的中日双语 EPUB - 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;同系列书籍会优先套用系列提示词和术语表配置,任务在后台运行,页面实时显示进度与日志。
- 直接编辑中文译文。 - 直接编辑中文译文。
- 标记问题段落或选中文本。 - 标记问题段落或选中文本。
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。 - 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
@@ -236,7 +224,7 @@ epub_review_sessions\<epub-name>_<hash>\
- 想直接修正译文时:编辑中文译文后保存本段。 - 想直接修正译文时:编辑中文译文后保存本段。
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。 - 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。
- 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。 - 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
-翻译整本书时:从 Readest 书架点击“全书翻译”进入 `/full-book-translation`,创建或调整系列归类,检查 EPUB ruby 候选;需要增删译名时先修改正式 `mingcibiao.json`,再显式确认当前扫描并预览 chunk。默认高质量模式执行首译、独立复审和一致性检查;中日双语结果完成后再进入 `/review-editor` 逐段审校。 -整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架封面悬停后点击“翻译”,保存 API 设置,并在系列设置里保存该系列术语表和提示词。默认先做前 20 段试译,确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校
- 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。 - 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。
- 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。 - 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl` - 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl`
@@ -244,8 +232,7 @@ epub_review_sessions\<epub-name>_<hash>\
## 当前限制 ## 当前限制
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。 - 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- `/review-editor` 不提供全书翻译;其中 GPT 重翻只用于修正当前审校段落。正式全书翻译必须在 `/full-book-translation` 完成候选术语确认与 chunk 规划后启动,并由任务执行独立复审和一致性 QA - AI 翻译模块是可选辅助首译入口,不替代正式长卷的候选词确认、复审和 QA;默认试译前 20 段,避免误触发全书 API 成本
- 任务创建时会保存模型、Base URL、分层提示词、术语表路径、质量模式与 chunk 目标等设置,并把当前正式术语表内容冻结到任务目录;源 EPUB、冻结设置和冻结术语表共同生成 fingerprint,恢复断点前必须校验一致。任务启动后修改正式术语表只影响后续任务。
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。 - 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。 - GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。 - 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
File diff suppressed because it is too large Load Diff
@@ -4,13 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title> <title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css?v=0.18.1"> <link rel="stylesheet" href="/static/style.css?v=0.16.5">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button> <button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock"> <div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.18.1</span></h1> <h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.5</span></h1>
<p id="sessionMeta">正在载入……</p> <p id="sessionMeta">正在载入……</p>
</div> </div>
<div class="modeTabs" role="tablist" aria-label="审校模式"> <div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -509,6 +509,6 @@
</aside> </aside>
<div class="toast" id="toast" hidden></div> <div class="toast" id="toast" hidden></div>
<script src="/static/app.js?v=0.18.1"></script> <script src="/static/app.js?v=0.16.5"></script>
</body> </body>
</html> </html>
@@ -1,480 +0,0 @@
import importlib.util
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH)
server = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(server)
class FullBookTranslationTests(unittest.TestCase):
def test_default_model_targets_gpt_5_6_sol(self):
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-5.6-sol")
def test_chunk_plan_targets_chunk_count_and_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_chunk_messages_request_one_structured_translation_per_block(self):
items = [
{
"id": "T00001",
"file": "a.xhtml",
"document_title": "A",
"source_html": "勇者は剣を抜いた。",
"source_text": "勇者は剣を抜いた。",
},
{
"id": "T00002",
"file": "a.xhtml",
"document_title": "A",
"source_html": "彼は前を見た。",
"source_text": "彼は前を見た。",
},
]
messages, glossary_by_item = server.build_translation_chunk_messages(
items,
items,
{"translation_prompt": "自然地翻译", "character_prompt": "保持角色口吻"},
{"勇者": "勇者"},
)
prompt = "\n".join(message["content"] for message in messages)
self.assertIn("T00001", prompt)
self.assertIn("T00002", prompt)
self.assertIn('"translated_html"', prompt)
self.assertEqual(glossary_by_item["T00001"], ["勇者 => 勇者"])
self.assertEqual(glossary_by_item["T00002"], [])
def test_chunk_response_requires_complete_ordered_block_ids(self):
items = [{"id": "T00001"}, {"id": "T00002"}]
raw = json.dumps(
{
"translations": [
{"block_id": "T00001", "translated_html": "第一段"},
{"block_id": "T00002", "translated_html": "第二段"},
]
},
ensure_ascii=False,
)
parsed = server.parse_translation_chunk_response(raw, items)
self.assertEqual(parsed, {"T00001": "第一段", "T00002": "第二段"})
incomplete = json.dumps(
{"translations": [{"block_id": "T00001", "translated_html": "第一段"}]},
ensure_ascii=False,
)
with self.assertRaisesRegex(ValueError, "数量"):
server.parse_translation_chunk_response(incomplete, items)
def test_quality_review_response_also_requires_exact_block_ids(self):
items = [{"id": "T00001"}, {"id": "T00002"}]
raw = json.dumps(
{
"translations": [
{"block_id": "T00001", "translated_html": "复审一"},
{"block_id": "T00002", "translated_html": "复审二"},
]
},
ensure_ascii=False,
)
parsed = server.parse_translation_chunk_response(raw, items)
self.assertEqual(parsed["T00002"], "复审二")
messages = server.build_translation_review_chunk_messages(
items,
{"T00001": "初译一", "T00002": "初译二"},
{"translation_prompt": "忠实自然", "character_prompt": "保持口吻"},
{"T00001": [], "T00002": []},
)
prompt = "\n".join(message["content"] for message in messages)
self.assertIn("初译一", prompt)
self.assertIn("逐段复审", prompt)
def test_consistency_qa_reports_missing_formal_term_and_japanese(self):
items = [
{
"id": "T00001",
"source_html": "勇者は剣を抜いた。",
"source_text": "勇者は剣を抜いた。",
}
]
issues = server.translation_consistency_issues(
items, {"T00001": "冒险者は拔出了剑。"}, {"勇者": "勇者"}
)
self.assertEqual({issue["kind"] for issue in issues}, {"glossary", "japanese"})
def test_job_settings_default_to_bilingual_quality_chunks(self):
settings = server.normalize_translation_job_settings({}, {})
self.assertEqual(settings["output_mode"], "bilingual")
self.assertEqual(settings["range_mode"], "all")
self.assertEqual(settings["quality_mode"], "quality")
def test_series_settings_are_frozen_when_job_is_created(self):
config = {
"glossary_path": "C:/global/mingcibiao.json",
"translation_prompt": "全局翻译规则",
"format_prompt": "全局格式规则",
"character_prompt": "全局角色规则",
}
series_config = {
"glossary_path": "C:/series/mingcibiao.json",
"translation_prompt": "系列翻译规则",
"format_prompt": "系列格式规则",
"character_prompt": "系列角色规则",
}
settings = server.normalize_translation_job_settings(
{}, config, series_config=series_config
)
self.assertEqual(Path(settings["glossary_path"]), Path("C:/series/mingcibiao.json"))
self.assertEqual(settings["translation_prompt"], "系列翻译规则")
self.assertEqual(settings["format_prompt"], "系列格式规则")
self.assertEqual(settings["character_prompt"], "系列角色规则")
def test_translation_start_rejects_another_active_job_for_same_book(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
source_epub = root / "book.epub"
source_epub.write_bytes(b"epub")
server.write_json(
session / "review_state" / "state.json", {"source_epub": str(source_epub)}
)
server.write_json(
server.session_manifest_path(session),
{
"id": session.name,
"source_name": "book.epub",
"source_epub": str(source_epub),
"metadata": {"title": ""},
},
)
server.write_translation_job(
root,
{
"id": "tr_running",
"status": "running",
"source_session_id": session.name,
"settings": {},
"logs": [],
},
)
app = server.create_app(root)
with patch.object(server, "run_translation_job"):
response = app.test_client().post(
"/api/translation/start", json={"session_id": session.name}
)
self.assertEqual(response.status_code, 409)
self.assertIn("已有", response.get_json()["error"])
def test_stale_running_jobs_are_reconciled_after_restart(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
server.write_translation_job(
root,
{
"id": "tr_stale",
"status": "running",
"source_session_id": "book",
"settings": {},
"logs": [],
},
)
server.create_app(root)
job = server.read_translation_job(root, "tr_stale")
self.assertEqual(job["status"], "paused")
self.assertIn("服务重启", job["progress"]["message"])
def test_resume_does_not_spawn_a_second_worker_when_paused_worker_is_alive(self):
class AliveThread:
def is_alive(self):
return True
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
server.write_translation_job(
root,
{
"id": "tr_paused",
"status": "paused",
"control": "pause",
"source_session_id": "book",
"settings": {},
"logs": [],
},
)
app = server.create_app(root)
app.config["TRANSLATION_THREADS"]["tr_paused"] = AliveThread()
with patch.object(server.threading, "Thread") as thread_class:
response = app.test_client().post(
"/api/translation/jobs/tr_paused/resume"
)
self.assertEqual(response.status_code, 200)
thread_class.assert_not_called()
job = server.read_translation_job(root, "tr_paused")
self.assertEqual(job["control"], "")
self.assertEqual(job["status"], "running")
def test_frozen_job_inputs_detect_source_or_settings_changes(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "book.epub"
source.write_bytes(b"original")
settings = {"model": "gpt-test", "translation_prompt": "rule"}
glossary = {"勇者": "勇者"}
frozen = server.build_translation_input_fingerprint(source, settings, glossary)
server.validate_translation_input_fingerprint(source, settings, glossary, frozen)
source.write_bytes(b"changed")
with self.assertRaisesRegex(ValueError, "fingerprint"):
server.validate_translation_input_fingerprint(source, settings, glossary, frozen)
source.write_bytes(b"original")
with self.assertRaisesRegex(ValueError, "fingerprint"):
server.validate_translation_input_fingerprint(
source,
{**settings, "translation_prompt": "new rule"},
glossary,
frozen,
)
def test_qa_failure_invalidates_only_chunks_containing_issues(self):
chunks = [
{"id": "C0001", "first_item_id": "T00001", "last_item_id": "T00002"},
{"id": "C0002", "first_item_id": "T00003", "last_item_id": "T00004"},
]
items = [{"id": f"T{index:05d}"} for index in range(1, 5)]
translations = {item["id"]: "译文" for item in items}
completed = {"C0001", "C0002"}
invalidated = server.invalidate_qa_issue_chunks(
chunks,
items,
[{"item_id": "T00003", "kind": "japanese"}],
translations,
completed,
)
self.assertEqual(invalidated, {"C0002"})
self.assertEqual(completed, {"C0001"})
self.assertEqual(set(translations), {"T00001", "T00002"})
def test_default_character_prompt_is_series_neutral(self):
self.assertNotIn("TRPG", server.DEFAULT_CHARACTER_PROMPT)
self.assertNotIn("第一人称", server.DEFAULT_CHARACTER_PROMPT)
def test_candidate_scan_finds_ruby_terms_not_already_in_formal_glossary(self):
items = [
{
"id": "T00001",
"file": "a.xhtml",
"source_html": (
"<ruby><rb>勇者</rb><rt>ブレイブ</rt></ruby>と"
"<ruby><rb>聖剣</rb><rt>エクスカリバー</rt></ruby>"
),
"source_text": "勇者と聖剣",
}
]
candidates = server.scan_translation_term_candidates(items, {"勇者": "勇者"})
self.assertEqual(len(candidates), 1)
self.assertEqual(candidates[0]["source"], "聖剣(エクスカリバー)")
self.assertEqual(candidates[0]["status"], "pending_user_confirmation")
def test_job_requires_explicit_term_confirmation(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
source_epub = root / "book.epub"
source_epub.write_bytes(b"epub")
server.write_json(
session / "review_state" / "state.json", {"source_epub": str(source_epub)}
)
server.write_json(
server.session_manifest_path(session),
{
"id": session.name,
"source_name": "book.epub",
"source_epub": str(source_epub),
"metadata": {"title": ""},
},
)
app = server.create_app(root)
with patch.object(
server,
"read_gpt_config",
return_value={
"configured": True,
"api_key": "secret",
"base_url": server.DEFAULT_GPT_BASE_URL,
"model": server.DEFAULT_GPT_MODEL,
},
):
response = app.test_client().post(
"/api/translation/start", json={"session_id": session.name}
)
self.assertEqual(response.status_code, 409)
self.assertIn("术语", response.get_json()["error"])
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_empty_glossary_path_loads_as_empty_mapping(self):
self.assertEqual(server.read_glossary_mapping_from_path(Path("")), {})
def test_preflight_confirmation_is_invalidated_when_glossary_targets_change(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
glossary_file = root / "mingcibiao.json"
server.write_json(glossary_file, {"勇者": "勇者"})
with patch.object(server, "extract_translation_source_items", return_value=[]):
confirmed = server.save_translation_preflight_confirmation(
session, str(glossary_file), True
)
self.assertTrue(confirmed["confirmed"])
server.write_json(glossary_file, {"勇者": "英雄"})
refreshed = server.translation_preflight_payload(
session, str(glossary_file)
)
self.assertFalse(refreshed["confirmed"])
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_job_does_not_leave_book_marked_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"], "untranslated")
def test_manual_series_assignment_survives_library_rebuild(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
server.write_json(
server.session_manifest_path(session),
{
"id": session.name,
"source_name": "book.epub",
"source_epub": "book.epub",
"metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"},
},
)
server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"})
library = server.save_library_series_overrides(
root,
{"action": "assign", "session_id": session.name, "series_id": "manual"},
)
book = next(item for item in library["sessions"] if item["id"] == session.name)
self.assertEqual(book["series_id"], "manual")
self.assertEqual(book["series"], "手动系列")
rebuilt = server.build_library_index(root)
rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name)
self.assertEqual(rebuilt_book["series_id"], "manual")
def test_models_endpoint_returns_only_public_ids(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = server.create_app(Path(temp_dir))
with patch.object(server, "read_scoped_gpt_config", return_value={
"api_key": "secret-value",
"base_url": "https://example.test/v1",
"model": "gpt-5.6-sol",
}), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]):
response = app.test_client().get("/api/gpt/models")
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertEqual(body["models"], ["gpt-5.6-sol"])
self.assertNotIn("secret-value", str(body))
def test_upload_endpoint_materializes_web_epub_into_current_review_root(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = Path(__file__).parents[2] / "src" / "__tests__" / "fixtures" / "data" / "sample-alice.epub"
app = server.create_app(root)
response = app.test_client().post(
"/api/upload",
data={"epub": (io.BytesIO(source.read_bytes()), "web-upload.epub")},
content_type="multipart/form-data",
)
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertTrue(Path(body["source_epub"]).is_file())
self.assertEqual(Path(body["source_epub"]).parent, root / server.UPLOAD_DIR_NAME)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,4 @@
version = "0.18.1" version = "0.16.5"
VERSION_RULES = { VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修", "PATCH": "修复 bug 或兼容性小修",
+6 -63
View File
@@ -1,7 +1,6 @@
param( param(
[string]$Remote = "origin", [string]$Remote = "akai-tools",
[string]$Branch = "codex/full-book-translation", [string]$Branch = "codex/desktop-review-editor-blocks",
[switch]$Web,
[switch]$SkipPull, [switch]$SkipPull,
[switch]$CheckOnly, [switch]$CheckOnly,
[switch]$KeepRunning [switch]$KeepRunning
@@ -65,24 +64,6 @@ function Stop-OldReadestDev {
} }
} }
function Start-ReadestWeb {
Write-Step "Starting Readest web development server"
Write-Host "Refresh http://localhost:3000 to see the latest committed changes."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "dev-web")
}
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 Assert-CleanWorktree { function Assert-CleanWorktree {
$status = (& git -C $RepoRoot status --porcelain) $status = (& git -C $RepoRoot status --porcelain)
if ($status) { if ($status) {
@@ -142,24 +123,7 @@ 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"
$requiredSubmodules = @( Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
"packages/foliate-js",
"packages/simplecc-wasm",
"packages/js-mdict",
"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) {
@@ -177,23 +141,6 @@ try {
Invoke-Checked "pnpm" @("install", "--frozen-lockfile") Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
} }
Write-Step "Building local submodule dependencies"
Invoke-Checked "pnpm" @(
"--dir",
(Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"),
"install",
"--ignore-workspace",
"--frozen-lockfile"
)
Invoke-Checked "pnpm" @(
"--dir",
(Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"),
"build"
)
Write-Step "Preparing browser runtime assets"
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "setup-vendors")
if ($CheckOnly) { if ($CheckOnly) {
Write-Step "Launcher check complete" Write-Step "Launcher check complete"
exit 0 exit 0
@@ -201,13 +148,9 @@ try {
Stop-OldReadestDev Stop-OldReadestDev
if ($Web) { Write-Step "Starting Readest desktop with the latest code"
Start-ReadestWeb Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
} else { Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
Write-Step "Starting Readest desktop with the latest code"
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")
}
} catch { } catch {
Write-Host "" Write-Host ""
Write-Host "Readest launcher failed:" -ForegroundColor Red Write-Host "Readest launcher failed:" -ForegroundColor Red