Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d0732e579 | |||
| 3938c9c8bd | |||
| 64508fd336 |
@@ -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": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
"copy-flatten-pdfjs-annotation-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
"copy-flatten-pdfjs-text-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||||
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
||||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
||||||
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
|
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { 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,8 +46,6 @@ 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[],
|
||||||
@@ -140,43 +138,6 @@ 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'
|
||||||
@@ -292,10 +253,30 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
ensureDependencies();
|
ensureDependencies();
|
||||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||||
|
|
||||||
launchDetachedEditor(serverPath);
|
const launch = run(
|
||||||
const launchedUrl = normalizeLoopbackUrl(await waitForRunningEditor());
|
venvPython(),
|
||||||
|
[
|
||||||
|
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('审校器已启动但未返回访问地址,请稍后重试');
|
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -169,21 +169,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
|||||||
async (book: Book, mode: 'review' | 'translate') => {
|
async (book: Book, mode: 'review' | 'translate') => {
|
||||||
const available = await makeBookAvailable(book);
|
const available = await makeBookAvailable(book);
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
const epubPath = appService?.isDesktopApp
|
const epubPath = await appService?.resolveNativeBookFilePath(book);
|
||||||
? await appService.resolveNativeBookFilePath(book)
|
if (!epubPath) {
|
||||||
: null;
|
|
||||||
if (appService?.isDesktopApp && !epubPath) {
|
|
||||||
eventDispatcher.dispatch('toast', {
|
eventDispatcher.dispatch('toast', {
|
||||||
message: _('Book file is not available locally'),
|
message: _('Book file is not available locally'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem('reviewEditorLaunchContext', JSON.stringify({ mode, epubPath }));
|
||||||
'reviewEditorLaunchContext',
|
const params = new URLSearchParams({ mode });
|
||||||
JSON.stringify(epubPath ? { mode, epubPath } : { mode, bookHash: book.hash }),
|
|
||||||
);
|
|
||||||
const params = new URLSearchParams(epubPath ? { mode } : { mode, book_hash: book.hash });
|
|
||||||
navigateToReviewEditor(router, params);
|
navigateToReviewEditor(router, params);
|
||||||
},
|
},
|
||||||
[_, appService, makeBookAvailable, router],
|
[_, appService, makeBookAvailable, router],
|
||||||
@@ -264,7 +259,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
translateInEpubEditor: {
|
translateInEpubEditor: {
|
||||||
text: '用 EPUB 审校器翻译',
|
text: '全书翻译',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await openBookInReviewEditor(book, 'translate');
|
await openBookInReviewEditor(book, 'translate');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -32,10 +31,8 @@ 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 (!canLaunchReviewEditor || bookData?.book?.format !== 'EPUB') return null;
|
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
|
||||||
|
|
||||||
const handleToggleReviewMode = async () => {
|
const handleToggleReviewMode = async () => {
|
||||||
if (appService?.isMobile) {
|
if (appService?.isMobile) {
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ 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 { useEnv } from '@/context/EnvContext';
|
|
||||||
import { isTauriAppPlatform } from '@/services/environment';
|
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';
|
||||||
@@ -41,7 +39,6 @@ type LaunchResponse = {
|
|||||||
type LaunchContext = {
|
type LaunchContext = {
|
||||||
mode?: LaunchMode;
|
mode?: LaunchMode;
|
||||||
epubPath?: string;
|
epubPath?: string;
|
||||||
bookHash?: string;
|
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,6 +163,7 @@ type TranslationSourcePayload = {
|
|||||||
type TranslationJob = {
|
type TranslationJob = {
|
||||||
id?: string;
|
id?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
source_name?: string;
|
||||||
output_epub?: string;
|
output_epub?: string;
|
||||||
output_session_id?: string;
|
output_session_id?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -211,9 +209,25 @@ type TranslationFormState = {
|
|||||||
range_mode: 'limit' | 'all';
|
range_mode: 'limit' | 'all';
|
||||||
limit: number;
|
limit: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
chunk_target: number;
|
||||||
use_series_config: boolean;
|
use_series_config: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TranslationPlanPayload = {
|
||||||
|
source_count: number;
|
||||||
|
chunk_target: number;
|
||||||
|
chunk_count: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
chunks: Array<{
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
first_item_id: string;
|
||||||
|
last_item_id: string;
|
||||||
|
item_count: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
const featureBlocks: Array<{
|
const featureBlocks: Array<{
|
||||||
mode: LaunchMode;
|
mode: LaunchMode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -256,6 +270,7 @@ const emptyTranslationForm: TranslationFormState = {
|
|||||||
range_mode: 'limit',
|
range_mode: 'limit',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
|
chunk_target: 24,
|
||||||
use_series_config: false,
|
use_series_config: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,9 +366,8 @@ const getSessionTitle = (session: SessionSummary | SessionPayload | null) =>
|
|||||||
|
|
||||||
async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit = {}): Promise<T> {
|
async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const url = new URL(path, baseUrl);
|
const url = new URL(path, baseUrl);
|
||||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
|
||||||
const headers =
|
const headers =
|
||||||
options.body === undefined || isFormData
|
options.body === undefined
|
||||||
? options.headers
|
? options.headers
|
||||||
: {
|
: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -370,15 +384,6 @@ async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit
|
|||||||
return data as T;
|
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) =>
|
const openSessionBody = (sessionId: string, activate = true) =>
|
||||||
JSON.stringify({ session_id: sessionId, activate });
|
JSON.stringify({ session_id: sessionId, activate });
|
||||||
|
|
||||||
@@ -397,9 +402,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1148,6 +1150,8 @@ function TranslateBlock({
|
|||||||
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
|
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
|
||||||
const [form, setForm] = useState<TranslationFormState>(emptyTranslationForm);
|
const [form, setForm] = useState<TranslationFormState>(emptyTranslationForm);
|
||||||
const [job, setJob] = useState<TranslationJob | null>(null);
|
const [job, setJob] = useState<TranslationJob | null>(null);
|
||||||
|
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
|
||||||
|
const [recentJobs, setRecentJobs] = useState<TranslationJob[]>([]);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [gptLoaded, setGptLoaded] = useState(false);
|
const [gptLoaded, setGptLoaded] = useState(false);
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
@@ -1166,17 +1170,30 @@ function TranslateBlock({
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
setGptLoaded(false);
|
setGptLoaded(false);
|
||||||
try {
|
try {
|
||||||
const [sourcePayload, defaultsPayload] = await Promise.all([
|
const [sourcePayload, defaultsPayload, jobsPayload] = await Promise.all([
|
||||||
sidecarApi<TranslationSourcePayload>(
|
sidecarApi<TranslationSourcePayload>(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
|
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
|
||||||
),
|
),
|
||||||
sidecarApi<TranslationDefaultsPayload>(baseUrl, '/api/translation/defaults'),
|
sidecarApi<TranslationDefaultsPayload>(baseUrl, '/api/translation/defaults'),
|
||||||
|
sidecarApi<{ jobs?: TranslationJob[] }>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/translation/jobs?session_id=${encodeURIComponent(sessionId)}`,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
const defaults = defaultsPayload.defaults || {};
|
const defaults = defaultsPayload.defaults || {};
|
||||||
const gpt = defaultsPayload.gpt || {};
|
const gpt = defaultsPayload.gpt || {};
|
||||||
const seriesConfig = sourcePayload.series_config || {};
|
const seriesConfig = sourcePayload.series_config || {};
|
||||||
setSource(sourcePayload);
|
setSource(sourcePayload);
|
||||||
|
setRecentJobs(jobsPayload.jobs || []);
|
||||||
|
const activeJob = (jobsPayload.jobs || []).find((item) =>
|
||||||
|
['pending', 'running', 'paused'].includes(item.status || ''),
|
||||||
|
);
|
||||||
|
if (activeJob?.id) {
|
||||||
|
setJob(activeJob);
|
||||||
|
currentJobIdRef.current = activeJob.id;
|
||||||
|
pollTimerRef.current = window.setTimeout(() => void pollJob(activeJob.id!), 400);
|
||||||
|
}
|
||||||
setForm({
|
setForm({
|
||||||
base_url: gpt.base_url || '',
|
base_url: gpt.base_url || '',
|
||||||
model: gpt.model || '',
|
model: gpt.model || '',
|
||||||
@@ -1196,6 +1213,7 @@ function TranslateBlock({
|
|||||||
range_mode: defaults.range_mode === 'all' ? 'all' : 'limit',
|
range_mode: defaults.range_mode === 'all' ? 'all' : 'limit',
|
||||||
limit: Number(defaults.limit || 20),
|
limit: Number(defaults.limit || 20),
|
||||||
temperature: Number(defaults.temperature ?? 0.2),
|
temperature: Number(defaults.temperature ?? 0.2),
|
||||||
|
chunk_target: 24,
|
||||||
use_series_config: Boolean(
|
use_series_config: Boolean(
|
||||||
seriesConfig.translation_prompt ||
|
seriesConfig.translation_prompt ||
|
||||||
seriesConfig.format_prompt ||
|
seriesConfig.format_prompt ||
|
||||||
@@ -1212,6 +1230,20 @@ function TranslateBlock({
|
|||||||
}
|
}
|
||||||
}, [baseUrl, sessionId]);
|
}, [baseUrl, sessionId]);
|
||||||
|
|
||||||
|
const previewPlan = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const payload = await sidecarApi<TranslationPlanPayload>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/session/${encodeURIComponent(sessionId)}/translation-plan?chunk_target=${encodeURIComponent(String(form.chunk_target))}`,
|
||||||
|
);
|
||||||
|
setPlan(payload);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}, [baseUrl, form.chunk_target, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTranslationData();
|
void loadTranslationData();
|
||||||
return clearPoll;
|
return clearPoll;
|
||||||
@@ -1306,6 +1338,7 @@ function TranslateBlock({
|
|||||||
range_mode: form.range_mode,
|
range_mode: form.range_mode,
|
||||||
limit: form.limit,
|
limit: form.limit,
|
||||||
temperature: form.temperature,
|
temperature: form.temperature,
|
||||||
|
chunk_target: form.chunk_target,
|
||||||
base_url: form.base_url,
|
base_url: form.base_url,
|
||||||
model: form.model,
|
model: form.model,
|
||||||
glossary_path: form.glossary_path,
|
glossary_path: form.glossary_path,
|
||||||
@@ -1336,12 +1369,30 @@ function TranslateBlock({
|
|||||||
const progress = job?.progress || {};
|
const progress = job?.progress || {};
|
||||||
const percent = Number(progress.percent || 0);
|
const percent = Number(progress.percent || 0);
|
||||||
const canOpenOutput = Boolean(job?.output_session_id);
|
const canOpenOutput = Boolean(job?.output_session_id);
|
||||||
|
const controlJob = async (action: 'pause' | 'resume' | 'cancel') => {
|
||||||
|
if (!job?.id) return;
|
||||||
|
try {
|
||||||
|
const payload = await sidecarApi<TranslationJobPayload>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/translation/jobs/${encodeURIComponent(job.id)}/${action}`,
|
||||||
|
{ method: 'POST', body: '{}' },
|
||||||
|
);
|
||||||
|
setJob(payload.job || null);
|
||||||
|
if (action === 'resume') {
|
||||||
|
currentJobIdRef.current = job.id;
|
||||||
|
clearPoll();
|
||||||
|
pollTimerRef.current = window.setTimeout(() => void pollJob(job.id!), 400);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return (
|
return (
|
||||||
<EmptyFeatureState
|
<EmptyFeatureState
|
||||||
title='还没有选择要翻译的 EPUB'
|
title='还没有选择要翻译的 EPUB'
|
||||||
description='请从 Readest 书架的 EPUB 右键菜单进入“用 EPUB 审校器翻译”。'
|
description='请从 Readest 书架的 EPUB 右键菜单进入“全书翻译”。'
|
||||||
actionLabel='打开旧版书架/上传页'
|
actionLabel='打开旧版书架/上传页'
|
||||||
onAction={() => openExternalUrl(baseUrl)}
|
onAction={() => openExternalUrl(baseUrl)}
|
||||||
/>
|
/>
|
||||||
@@ -1376,6 +1427,40 @@ function TranslateBlock({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{recentJobs.length ? (
|
||||||
|
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
|
||||||
|
<h3 className='mb-3 text-sm font-semibold'>本书翻译任务</h3>
|
||||||
|
<div className='grid gap-2' aria-live='polite'>
|
||||||
|
{recentJobs.slice(0, 6).map((item) => (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setJob(item);
|
||||||
|
if (
|
||||||
|
item.id &&
|
||||||
|
!['completed', 'failed', 'cancelled'].includes(item.status || '')
|
||||||
|
) {
|
||||||
|
currentJobIdRef.current = item.id;
|
||||||
|
clearPoll();
|
||||||
|
pollTimerRef.current = window.setTimeout(() => void pollJob(item.id!), 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='eink-bordered border-base-300 hover:bg-base-200 grid grid-cols-[1fr_auto] items-center gap-3 rounded-md border px-3 py-2 text-left'
|
||||||
|
>
|
||||||
|
<span className='min-w-0'>
|
||||||
|
<strong className='block truncate text-sm'>{item.id}</strong>
|
||||||
|
<span className='text-base-content/60 block truncate text-xs'>
|
||||||
|
{item.progress?.message || item.source_name || ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className='text-xs'>{item.status || 'unknown'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||||
{message}
|
{message}
|
||||||
@@ -1390,7 +1475,7 @@ function TranslateBlock({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-4 rounded-md border p-4'>
|
<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'>
|
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-5'>
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
输出格式
|
输出格式
|
||||||
<select
|
<select
|
||||||
@@ -1440,6 +1525,22 @@ function TranslateBlock({
|
|||||||
disabled={form.range_mode === 'all'}
|
disabled={form.range_mode === 'all'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
每个 chunk 目标块数
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||||
|
value={form.chunk_target}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
chunk_target: Math.max(1, Number(event.target.value || 1)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
Temperature
|
Temperature
|
||||||
<input
|
<input
|
||||||
@@ -1470,6 +1571,9 @@ function TranslateBlock({
|
|||||||
启动任务时优先套用同系列配置
|
启动任务时优先套用同系列配置
|
||||||
</label>
|
</label>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<ToolbarButton onClick={previewPlan} icon={<FileText className='h-4 w-4' />}>
|
||||||
|
预览分块
|
||||||
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={saveSeriesConfig}
|
onClick={saveSeriesConfig}
|
||||||
icon={<Save className='h-4 w-4' />}
|
icon={<Save className='h-4 w-4' />}
|
||||||
@@ -1486,6 +1590,26 @@ function TranslateBlock({
|
|||||||
{starting ? '翻译运行中' : '开始翻译'}
|
{starting ? '翻译运行中' : '开始翻译'}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
|
{plan ? (
|
||||||
|
<div className='border-base-300 grid gap-2 border-t pt-3'>
|
||||||
|
<p className='text-sm font-medium'>
|
||||||
|
预计 {plan.chunk_count} 个 chunk · 输入约 {plan.estimated_input_tokens} tokens
|
||||||
|
</p>
|
||||||
|
<div className='max-h-48 overflow-auto' role='list' aria-label='翻译分块预览'>
|
||||||
|
{plan.chunks.map((chunk) => (
|
||||||
|
<div
|
||||||
|
key={chunk.id}
|
||||||
|
role='listitem'
|
||||||
|
className='border-base-300 border-b py-2 text-xs'
|
||||||
|
>
|
||||||
|
<strong>{chunk.id}</strong> · {chunk.title || '未命名章节'} ·{' '}
|
||||||
|
{chunk.first_item_id}–{chunk.last_item_id} · {chunk.item_count} 块 · 约{' '}
|
||||||
|
{chunk.estimated_input_tokens} tokens
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
|
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-4'>
|
||||||
@@ -1510,6 +1634,16 @@ function TranslateBlock({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')}
|
.join('\n')}
|
||||||
</pre>
|
</pre>
|
||||||
|
{job?.id && !['completed', 'cancelled'].includes(job.status || '') ? (
|
||||||
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
|
{job.status === 'paused' || job.status === 'failed' ? (
|
||||||
|
<ToolbarButton onClick={() => void controlJob('resume')}>恢复</ToolbarButton>
|
||||||
|
) : (
|
||||||
|
<ToolbarButton onClick={() => void controlJob('pause')}>暂停</ToolbarButton>
|
||||||
|
)}
|
||||||
|
<ToolbarButton onClick={() => void controlJob('cancel')}>取消</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{job?.logs?.length ? (
|
{job?.logs?.length ? (
|
||||||
<div className='mt-3 grid gap-2'>
|
<div className='mt-3 grid gap-2'>
|
||||||
{job.logs
|
{job.logs
|
||||||
@@ -1579,7 +1713,6 @@ 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 [activeMode, setActiveMode] = useState<LaunchMode>('review');
|
||||||
@@ -1601,23 +1734,6 @@ export default function ReviewEditorLauncher() {
|
|||||||
[activeMode, payload?.url, router],
|
[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);
|
||||||
@@ -1650,10 +1766,6 @@ export default function ReviewEditorLauncher() {
|
|||||||
);
|
);
|
||||||
nextSessionId = createdSession.id || null;
|
nextSessionId = createdSession.id || null;
|
||||||
}
|
}
|
||||||
if (!nextSessionId && context.bookHash) {
|
|
||||||
const createdSession = await createSessionFromBookHash(data.url, context.bookHash);
|
|
||||||
nextSessionId = createdSession.id || null;
|
|
||||||
}
|
|
||||||
if (!nextSessionId) {
|
if (!nextSessionId) {
|
||||||
const activeSession = await sidecarApi<SessionPayload>(data.url, '/api/session');
|
const activeSession = await sidecarApi<SessionPayload>(data.url, '/api/session');
|
||||||
nextSessionId = activeSession.has_session ? activeSession.id || null : null;
|
nextSessionId = activeSession.has_session ? activeSession.id || null : null;
|
||||||
@@ -1676,15 +1788,13 @@ export default function ReviewEditorLauncher() {
|
|||||||
});
|
});
|
||||||
setState('failed');
|
setState('failed');
|
||||||
}
|
}
|
||||||
}, [createSessionFromBookHash, router]);
|
}, [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();
|
||||||
}, [appService, launch]);
|
}, [launch]);
|
||||||
|
|
||||||
const switchMode = (mode: LaunchMode) => {
|
const switchMode = (mode: LaunchMode) => {
|
||||||
setActiveMode(mode);
|
setActiveMode(mode);
|
||||||
|
|||||||
@@ -114,6 +114,42 @@ export type ReviewGlossaryEntry = {
|
|||||||
clean_target?: string;
|
clean_target?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranslationChunkPlan = {
|
||||||
|
id: string;
|
||||||
|
file: string;
|
||||||
|
title?: string;
|
||||||
|
first_item_id: string;
|
||||||
|
last_item_id: string;
|
||||||
|
item_count: number;
|
||||||
|
source_characters: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationPlanPayload = {
|
||||||
|
session_id: string;
|
||||||
|
source_count: number;
|
||||||
|
chunk_target: number;
|
||||||
|
chunk_count: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
chunks: TranslationChunkPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationJobSummary = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
source_session_id: string;
|
||||||
|
source_name?: string;
|
||||||
|
progress?: {
|
||||||
|
current?: number;
|
||||||
|
total?: number;
|
||||||
|
percent?: number;
|
||||||
|
failed?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
output_session_id?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewGlossaryPayload = {
|
export type ReviewGlossaryPayload = {
|
||||||
path?: string;
|
path?: string;
|
||||||
exists?: boolean;
|
exists?: boolean;
|
||||||
@@ -148,9 +184,8 @@ export async function sidecarApi<T>(
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
url.searchParams.set('session_id', sessionId);
|
url.searchParams.set('session_id', sessionId);
|
||||||
}
|
}
|
||||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
|
||||||
const headers =
|
const headers =
|
||||||
options.body === undefined || isFormData
|
options.body === undefined
|
||||||
? options.headers
|
? options.headers
|
||||||
: {
|
: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -182,23 +217,6 @@ 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,
|
||||||
@@ -220,12 +238,10 @@ 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 = isTauriAppPlatform() ? await appService.resolveNativeBookFilePath(book) : null;
|
const epubPath = await appService.resolveNativeBookFilePath(book);
|
||||||
if (isTauriAppPlatform() && !epubPath) {
|
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||||
throw new Error('无法定位当前 EPUB 的本机文件路径');
|
|
||||||
}
|
|
||||||
|
|
||||||
const launch = epubPath
|
const launch = isTauriAppPlatform()
|
||||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
||||||
epubPath,
|
epubPath,
|
||||||
})
|
})
|
||||||
@@ -242,9 +258,7 @@ export async function launchInlineReviewEditor(
|
|||||||
|
|
||||||
let sessionId = launch.sessionId || null;
|
let sessionId = launch.sessionId || null;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
const createdSession = epubPath
|
const createdSession = await createReviewSessionFromPath(launch.url, epubPath);
|
||||||
? await createReviewSessionFromPath(launch.url, epubPath)
|
|
||||||
: await uploadReviewSession(launch.url, (await appService.loadBookContent(book)).file);
|
|
||||||
sessionId = createdSession.id || null;
|
sessionId = createdSession.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,3 +408,41 @@ export async function exportReviewedEpub(
|
|||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ 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`。
|
||||||
|
|
||||||
翻译输入与提示词:
|
翻译输入与提示词:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 双语 EPUB 审校编辑器
|
# 双语 EPUB 审校编辑器
|
||||||
|
|
||||||
当前版本:`0.16.5`
|
当前版本:`0.17.0`
|
||||||
|
|
||||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@
|
|||||||
|
|
||||||
`0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。
|
`0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。
|
||||||
|
|
||||||
|
`0.17.0` 启动 Readest 全书翻译工作台的可靠任务基座:默认模型更新为 `gpt-5.6-sol`,新增模型列表探测、分块计划预览、全局任务列表、暂停/恢复/取消和逐正文块原子断点保存。术语表继续使用 `mingcibiao.json` 的 JSON 对象格式,任务日志与公开 API 不返回密钥。
|
||||||
|
|
||||||
## 独立安装
|
## 独立安装
|
||||||
|
|
||||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ DEFAULT_REVIEW_ROOT = "epub_review_sessions"
|
|||||||
STATE_VERSION = 1
|
STATE_VERSION = 1
|
||||||
UPLOAD_DIR_NAME = "uploads"
|
UPLOAD_DIR_NAME = "uploads"
|
||||||
DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1"
|
DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1"
|
||||||
DEFAULT_GPT_MODEL = "gpt-4o-mini"
|
DEFAULT_GPT_MODEL = "gpt-5.6-sol"
|
||||||
DEFAULT_TRANSLATION_PROMPT = (
|
DEFAULT_TRANSLATION_PROMPT = (
|
||||||
"你是日语轻小说到简体中文的审校重翻助手。只重翻当前目标语句/段落。"
|
"你是日语轻小说到简体中文的审校重翻助手。只重翻当前目标语句/段落。"
|
||||||
"目标是自然、流畅、忠实的简体中文轻小说文风。意义优先,再调整为中文语序;"
|
"目标是自然、流畅、忠实的简体中文轻小说文风。意义优先,再调整为中文语序;"
|
||||||
@@ -104,6 +104,8 @@ TRANSLATION_LOG_LIMIT = 80
|
|||||||
DEFAULT_TRANSLATION_LIMIT = 20
|
DEFAULT_TRANSLATION_LIMIT = 20
|
||||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||||
TRANSLATION_CONTEXT_RADIUS = 1
|
TRANSLATION_CONTEXT_RADIUS = 1
|
||||||
|
DEFAULT_TRANSLATION_CHUNK_TARGET = 24
|
||||||
|
TRANSLATION_CHUNK_TARGET_MAX = 120
|
||||||
LIBRARY_DB_NAME = "library.json"
|
LIBRARY_DB_NAME = "library.json"
|
||||||
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
||||||
|
|
||||||
@@ -1122,6 +1124,32 @@ def call_openai_compatible_chat(
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def list_openai_compatible_models(config: dict[str, Any]) -> list[str]:
|
||||||
|
api_key = str(config.get("api_key") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("尚未配置 GPT API Key")
|
||||||
|
base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base_url}/models",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = redact_secret(exc.read().decode("utf-8", errors="replace"), api_key)[:1200]
|
||||||
|
raise RuntimeError(f"模型列表请求失败:HTTP {exc.code} {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"模型列表请求失败:{redact_secret(exc.reason, api_key)}") from exc
|
||||||
|
rows = result.get("data") if isinstance(result, dict) else []
|
||||||
|
return sorted(
|
||||||
|
str(row.get("id"))
|
||||||
|
for row in (rows or [])
|
||||||
|
if isinstance(row, dict) and str(row.get("id") or "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
||||||
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
||||||
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
||||||
@@ -1147,6 +1175,10 @@ def library_db_path(review_root: Path) -> Path:
|
|||||||
return review_root / LIBRARY_DB_NAME
|
return review_root / LIBRARY_DB_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def library_overrides_path(review_root: Path) -> Path:
|
||||||
|
return review_root / "library_overrides.json"
|
||||||
|
|
||||||
|
|
||||||
def series_config_path(review_root: Path, series_id: str) -> Path:
|
def series_config_path(review_root: Path, series_id: str) -> Path:
|
||||||
return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
|
return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
|
||||||
|
|
||||||
@@ -1289,7 +1321,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
|||||||
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
||||||
session_id = session_public_id(session_root)
|
session_id = session_public_id(session_root)
|
||||||
cover_asset_path = str(metadata.get("cover_asset_path") or "")
|
cover_asset_path = str(metadata.get("cover_asset_path") or "")
|
||||||
return {
|
summary = {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"version": version,
|
"version": version,
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
@@ -1316,6 +1348,13 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
|||||||
"latest_export": latest_export,
|
"latest_export": latest_export,
|
||||||
"reading_position": normalize_reading_position(state.get("reading_position", {})),
|
"reading_position": normalize_reading_position(state.get("reading_position", {})),
|
||||||
}
|
}
|
||||||
|
review_root = session_root.parent
|
||||||
|
overrides = read_json(library_overrides_path(review_root), {})
|
||||||
|
assignment = overrides.get("assignments", {}).get(session_id, {}) if isinstance(overrides, dict) else {}
|
||||||
|
if isinstance(assignment, dict) and assignment.get("series_id"):
|
||||||
|
summary["series_id"] = str(assignment["series_id"])
|
||||||
|
summary["series"] = str(assignment.get("series") or summary["series"])
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def rowTouched_py(row: dict[str, Any]) -> bool:
|
def rowTouched_py(row: dict[str, Any]) -> bool:
|
||||||
@@ -1351,6 +1390,19 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def build_library_index(review_root: Path) -> dict[str, Any]:
|
def build_library_index(review_root: Path) -> dict[str, Any]:
|
||||||
sessions = list_review_sessions(review_root)
|
sessions = list_review_sessions(review_root)
|
||||||
|
jobs_by_session: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for job in list_translation_jobs(review_root):
|
||||||
|
source_session_id = str(job.get("source_session_id") or "")
|
||||||
|
if source_session_id:
|
||||||
|
jobs_by_session.setdefault(source_session_id, []).append(job)
|
||||||
|
for session in sessions:
|
||||||
|
session_jobs = jobs_by_session.get(str(session.get("id") or ""), [])
|
||||||
|
completed = next((job for job in session_jobs if job.get("status") == "completed"), None)
|
||||||
|
latest = session_jobs[0] if session_jobs else None
|
||||||
|
session["translation_status"] = (
|
||||||
|
"translated" if completed else "translating" if latest else "untranslated"
|
||||||
|
)
|
||||||
|
session["translation_job"] = latest
|
||||||
series_map: dict[str, dict[str, Any]] = {}
|
series_map: dict[str, dict[str, Any]] = {}
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
series_id = str(session.get("series_id") or "uncategorized")
|
series_id = str(session.get("series_id") or "uncategorized")
|
||||||
@@ -1375,6 +1427,22 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
|||||||
series["publishers"].add(session["publisher"])
|
series["publishers"].add(session["publisher"])
|
||||||
if session.get("language"):
|
if session.get("language"):
|
||||||
series["languages"].add(session["language"])
|
series["languages"].add(session["language"])
|
||||||
|
overrides = read_json(library_overrides_path(review_root), {})
|
||||||
|
manual_series = overrides.get("series", {}) if isinstance(overrides, dict) else {}
|
||||||
|
if isinstance(manual_series, dict):
|
||||||
|
for series_id, title in manual_series.items():
|
||||||
|
series_map.setdefault(
|
||||||
|
str(series_id),
|
||||||
|
{
|
||||||
|
"id": str(series_id),
|
||||||
|
"title": str(title),
|
||||||
|
"count": 0,
|
||||||
|
"books": [],
|
||||||
|
"authors": set(),
|
||||||
|
"publishers": set(),
|
||||||
|
"languages": set(),
|
||||||
|
},
|
||||||
|
)
|
||||||
series_list = []
|
series_list = []
|
||||||
for item in series_map.values():
|
for item in series_map.values():
|
||||||
series_list.append(
|
series_list.append(
|
||||||
@@ -1398,6 +1466,44 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
|||||||
return library
|
return library
|
||||||
|
|
||||||
|
|
||||||
|
def save_library_series_overrides(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = read_json(library_overrides_path(review_root), {"series": {}, "assignments": {}})
|
||||||
|
current.setdefault("series", {})
|
||||||
|
current.setdefault("assignments", {})
|
||||||
|
action = str(data.get("action") or "").strip()
|
||||||
|
if action in {"create", "rename"}:
|
||||||
|
title = str(data.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("系列名称不能为空")
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or title), max_len=120)
|
||||||
|
current["series"][series_id] = title
|
||||||
|
elif action == "assign":
|
||||||
|
session_id = str(data.get("session_id") or "").strip()
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||||
|
if not session_id or not series_id:
|
||||||
|
raise ValueError("缺少 session_id 或 series_id")
|
||||||
|
session_root_from_id(review_root, session_id)
|
||||||
|
title = str(current["series"].get(series_id) or data.get("title") or series_id)
|
||||||
|
current["series"][series_id] = title
|
||||||
|
current["assignments"][session_id] = {"series_id": series_id, "series": title}
|
||||||
|
elif action == "delete":
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||||
|
if not series_id:
|
||||||
|
raise ValueError("缺少 series_id")
|
||||||
|
if any(
|
||||||
|
assignment.get("series_id") == series_id
|
||||||
|
for assignment in current["assignments"].values()
|
||||||
|
if isinstance(assignment, dict)
|
||||||
|
):
|
||||||
|
raise ValueError("系列仍有书籍,不能删除")
|
||||||
|
current["series"].pop(series_id, None)
|
||||||
|
else:
|
||||||
|
raise ValueError("不支持的系列操作")
|
||||||
|
current["updated_at"] = now_iso()
|
||||||
|
write_json_atomic(library_overrides_path(review_root), current)
|
||||||
|
return build_library_index(review_root)
|
||||||
|
|
||||||
|
|
||||||
def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
|
def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
|
||||||
path = series_config_path(review_root, series_id)
|
path = series_config_path(review_root, series_id)
|
||||||
data = read_json(path, {})
|
data = read_json(path, {})
|
||||||
@@ -2766,10 +2872,29 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
|
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
|
||||||
"glossary_path": settings.get("glossary_path", ""),
|
"glossary_path": settings.get("glossary_path", ""),
|
||||||
"temperature": settings.get("temperature", 0.2),
|
"temperature": settings.get("temperature", 0.2),
|
||||||
|
"chunk_target": settings.get("chunk_target", DEFAULT_TRANSLATION_CHUNK_TARGET),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_translation_jobs(review_root: Path) -> list[dict[str, Any]]:
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
root = translation_jobs_root(review_root)
|
||||||
|
if not root.exists():
|
||||||
|
return jobs
|
||||||
|
for child in root.iterdir():
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = read_translation_job(review_root, child.name)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
if job:
|
||||||
|
jobs.append(public_translation_job(job))
|
||||||
|
jobs.sort(key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), reverse=True)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||||
output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
|
output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
|
||||||
if output_mode not in {"bilingual", "translated"}:
|
if output_mode not in {"bilingual", "translated"}:
|
||||||
@@ -2787,6 +2912,11 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("temperature 必须是数字") from exc
|
raise ValueError("temperature 必须是数字") from exc
|
||||||
temperature = max(0.0, min(1.0, temperature))
|
temperature = max(0.0, min(1.0, temperature))
|
||||||
|
try:
|
||||||
|
chunk_target = int(data.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("每个 chunk 的目标正文块数必须是数字") from exc
|
||||||
|
chunk_target = max(1, min(chunk_target, TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
def prompt_value(name: str) -> Any:
|
def prompt_value(name: str) -> Any:
|
||||||
if data.get(f"use_series_{name}"):
|
if data.get(f"use_series_{name}"):
|
||||||
return None
|
return None
|
||||||
@@ -2798,6 +2928,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
"range_mode": range_mode,
|
"range_mode": range_mode,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
|
"chunk_target": chunk_target,
|
||||||
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "",
|
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "",
|
||||||
"translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")),
|
"translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")),
|
||||||
"format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
|
"format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
|
||||||
@@ -2805,6 +2936,44 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_translation_plan(
|
||||||
|
items: list[dict[str, Any]], chunk_target: int = DEFAULT_TRANSLATION_CHUNK_TARGET
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Group source blocks without crossing XHTML files, preserving stable source order."""
|
||||||
|
chunk_target = max(1, min(int(chunk_target), TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
|
chunks: list[dict[str, Any]] = []
|
||||||
|
current: list[dict[str, Any]] = []
|
||||||
|
current_file = ""
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
text_chars = sum(len(str(item.get("source_text") or "")) for item in current)
|
||||||
|
chunks.append(
|
||||||
|
{
|
||||||
|
"id": f"C{len(chunks) + 1:04d}",
|
||||||
|
"file": current[0].get("file", ""),
|
||||||
|
"title": current[0].get("document_title", ""),
|
||||||
|
"first_item_id": current[0].get("id", ""),
|
||||||
|
"last_item_id": current[-1].get("id", ""),
|
||||||
|
"item_count": len(current),
|
||||||
|
"source_characters": text_chars,
|
||||||
|
"estimated_input_tokens": max(1, round(text_chars / 1.7)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_file = str(item.get("file") or "")
|
||||||
|
if current and (item_file != current_file or len(current) >= chunk_target):
|
||||||
|
flush()
|
||||||
|
current_file = item_file
|
||||||
|
current.append(item)
|
||||||
|
flush()
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def html_looks_like_navigation(entry_name: str, text: str) -> bool:
|
def html_looks_like_navigation(entry_name: str, text: str) -> bool:
|
||||||
basename = posixpath.basename(entry_name).lower()
|
basename = posixpath.basename(entry_name).lower()
|
||||||
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
|
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
|
||||||
@@ -3121,6 +3290,22 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
if not selected_items:
|
if not selected_items:
|
||||||
raise ValueError("没有找到可翻译的日文正文块")
|
raise ValueError("没有找到可翻译的日文正文块")
|
||||||
|
|
||||||
|
checkpoint_path = translation_job_root(review_root, job_id) / "translations.json"
|
||||||
|
checkpoint = read_json(checkpoint_path, {})
|
||||||
|
if isinstance(checkpoint, dict):
|
||||||
|
saved_translations = checkpoint.get("translations")
|
||||||
|
if isinstance(saved_translations, dict):
|
||||||
|
translations.update(
|
||||||
|
{
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in saved_translations.items()
|
||||||
|
if key and isinstance(value, str)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
completed_ids = {item["id"] for item in selected_items if item["id"] in translations}
|
||||||
|
if completed_ids:
|
||||||
|
add_translation_job_log(job, f"已从断点恢复 {len(completed_ids)} 个正文块。")
|
||||||
|
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
"total": len(selected_items),
|
"total": len(selected_items),
|
||||||
@@ -3134,6 +3319,44 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
failed_count = 0
|
failed_count = 0
|
||||||
errors: list[dict[str, Any]] = []
|
errors: list[dict[str, Any]] = []
|
||||||
for index, item in enumerate(selected_items):
|
for index, item in enumerate(selected_items):
|
||||||
|
job = read_translation_job(review_root, job_id) or job
|
||||||
|
control = str(job.get("control") or "")
|
||||||
|
if control == "cancel":
|
||||||
|
job["status"] = "cancelled"
|
||||||
|
job["finished_at"] = now_iso()
|
||||||
|
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||||
|
add_translation_job_log(job, job["progress"]["message"])
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return
|
||||||
|
while control == "pause":
|
||||||
|
job["status"] = "paused"
|
||||||
|
job["progress"]["message"] = "任务已暂停,断点已保留。"
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
time.sleep(0.4)
|
||||||
|
job = read_translation_job(review_root, job_id) or job
|
||||||
|
control = str(job.get("control") or "")
|
||||||
|
if control == "cancel":
|
||||||
|
break
|
||||||
|
if control == "cancel":
|
||||||
|
job["status"] = "cancelled"
|
||||||
|
job["finished_at"] = now_iso()
|
||||||
|
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return
|
||||||
|
if job.get("status") == "paused":
|
||||||
|
job["status"] = "running"
|
||||||
|
job["control"] = ""
|
||||||
|
add_translation_job_log(job, "任务已恢复。")
|
||||||
|
if item["id"] in translations:
|
||||||
|
job["progress"].update(
|
||||||
|
{
|
||||||
|
"current": index + 1,
|
||||||
|
"percent": round((index + 1) / max(1, len(selected_items)) * 100, 1),
|
||||||
|
"message": f"已从断点跳过 {item['id']}({index + 1}/{len(selected_items)})",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
continue
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
"current": index,
|
"current": index,
|
||||||
@@ -3164,11 +3387,10 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text})
|
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text})
|
||||||
translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】"
|
translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】"
|
||||||
add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning")
|
add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning")
|
||||||
if index == 0 or (index + 1) % 5 == 0 or failed_count:
|
write_json_atomic(
|
||||||
write_json_atomic(
|
checkpoint_path,
|
||||||
translation_job_root(review_root, job_id) / "translations.json",
|
{"items": selected_items, "translations": translations, "errors": errors},
|
||||||
{"items": selected_items, "translations": translations, "errors": errors},
|
)
|
||||||
)
|
|
||||||
|
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
@@ -3406,6 +3628,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
def api_library():
|
def api_library():
|
||||||
return jsonify(build_library_index(review_root))
|
return jsonify(build_library_index(review_root))
|
||||||
|
|
||||||
|
@app.route("/api/library/series", methods=["POST"])
|
||||||
|
def api_library_series():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
return jsonify(save_library_series_overrides(review_root, data))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
@app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
|
@app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
|
||||||
def api_series_config(series_id: str):
|
def api_series_config(series_id: str):
|
||||||
series_id = safe_slug(series_id, max_len=120)
|
series_id = safe_slug(series_id, max_len=120)
|
||||||
@@ -3612,6 +3842,25 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
@app.route("/api/gpt/models")
|
||||||
|
def api_gpt_models():
|
||||||
|
session_root, _epub_path = session_from_request()
|
||||||
|
try:
|
||||||
|
config = read_scoped_gpt_config(review_root, session_root)
|
||||||
|
models = list_openai_compatible_models(config)
|
||||||
|
configured_model = str(config.get("model") or DEFAULT_GPT_MODEL)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"models": models,
|
||||||
|
"configured_model": configured_model,
|
||||||
|
"configured_model_available": configured_model in models,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
@app.route("/api/glossary", methods=["GET", "POST"])
|
@app.route("/api/glossary", methods=["GET", "POST"])
|
||||||
def api_glossary():
|
def api_glossary():
|
||||||
session_root, _epub_path = session_from_request()
|
session_root, _epub_path = session_from_request()
|
||||||
@@ -3748,6 +3997,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": f"读取翻译源失败:{exc}"}), 500
|
return jsonify({"error": f"读取翻译源失败:{exc}"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/session/<session_id>/translation-plan", methods=["GET", "POST"])
|
||||||
|
def api_translation_plan(session_id: str):
|
||||||
|
try:
|
||||||
|
source_root = session_root_from_id(review_root, session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if not (source_root / "review_state" / "state.json").exists() or session_is_hidden(source_root):
|
||||||
|
return jsonify({"error": "会话不存在或已移除"}), 404
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_target = data.get("chunk_target") or request.args.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET
|
||||||
|
try:
|
||||||
|
chunk_target = max(1, min(int(raw_target), TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "chunk_target 必须是数字"}), 400
|
||||||
|
items = extract_translation_source_items(source_root)
|
||||||
|
chunks = build_translation_plan(items, chunk_target)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"source_count": len(items),
|
||||||
|
"chunk_target": chunk_target,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"estimated_input_tokens": sum(chunk["estimated_input_tokens"] for chunk in chunks),
|
||||||
|
"chunks": chunks,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/api/translation/start", methods=["POST"])
|
@app.route("/api/translation/start", methods=["POST"])
|
||||||
def api_translation_start():
|
def api_translation_start():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -3816,6 +4092,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500
|
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/translation/jobs")
|
||||||
|
def api_translation_jobs():
|
||||||
|
jobs = list_translation_jobs(review_root)
|
||||||
|
session_id = str(request.args.get("session_id") or "").strip()
|
||||||
|
if session_id:
|
||||||
|
jobs = [job for job in jobs if job.get("source_session_id") == session_id]
|
||||||
|
return jsonify({"jobs": jobs, "count": len(jobs)})
|
||||||
|
|
||||||
@app.route("/api/translation/jobs/<job_id>")
|
@app.route("/api/translation/jobs/<job_id>")
|
||||||
def api_translation_job(job_id: str):
|
def api_translation_job(job_id: str):
|
||||||
try:
|
try:
|
||||||
@@ -3826,6 +4110,43 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
return jsonify({"error": "翻译任务不存在"}), 404
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
return jsonify({"job": public_translation_job(job)})
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
|
||||||
|
@app.route("/api/translation/jobs/<job_id>/<action>", methods=["POST"])
|
||||||
|
def api_translation_job_action(job_id: str, action: str):
|
||||||
|
if action not in {"pause", "resume", "cancel"}:
|
||||||
|
return jsonify({"error": "不支持的任务操作"}), 404
|
||||||
|
try:
|
||||||
|
job = read_translation_job(review_root, job_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
|
status = str(job.get("status") or "")
|
||||||
|
if action == "pause":
|
||||||
|
if status not in {"pending", "running"}:
|
||||||
|
return jsonify({"error": "当前任务不能暂停"}), 409
|
||||||
|
job["control"] = "pause"
|
||||||
|
add_translation_job_log(job, "已请求暂停,当前 API 调用完成后生效。")
|
||||||
|
elif action == "cancel":
|
||||||
|
if status in {"completed", "failed", "cancelled"}:
|
||||||
|
return jsonify({"error": "当前任务不能取消"}), 409
|
||||||
|
job["control"] = "cancel"
|
||||||
|
add_translation_job_log(job, "已请求取消,当前 API 调用完成后生效。")
|
||||||
|
else:
|
||||||
|
if status not in {"paused", "failed", "cancelled"}:
|
||||||
|
return jsonify({"error": "当前任务不能恢复"}), 409
|
||||||
|
job["control"] = ""
|
||||||
|
job["status"] = "pending"
|
||||||
|
job["finished_at"] = ""
|
||||||
|
job["error"] = ""
|
||||||
|
add_translation_job_log(job, "已请求从断点恢复。")
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
thread = threading.Thread(target=run_translation_job, args=(review_root, job_id), daemon=True)
|
||||||
|
app.config["TRANSLATION_THREADS"][job_id] = thread
|
||||||
|
thread.start()
|
||||||
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
|
||||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||||
def api_retranslate_row(row_id: str):
|
def api_retranslate_row(row_id: str):
|
||||||
session_root, _epub_path = session_from_request()
|
session_root, _epub_path = session_from_request()
|
||||||
@@ -4078,11 +4399,7 @@ def run_daemon(args: argparse.Namespace) -> int:
|
|||||||
creationflags = 0
|
creationflags = 0
|
||||||
popen_kwargs: dict[str, Any] = {}
|
popen_kwargs: dict[str, Any] = {}
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
creationflags = (
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||||
subprocess.CREATE_NEW_PROCESS_GROUP
|
|
||||||
| subprocess.DETACHED_PROCESS
|
|
||||||
| subprocess.CREATE_NO_WINDOW
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
popen_kwargs["start_new_session"] = True
|
popen_kwargs["start_new_session"] = True
|
||||||
with log_path.open("a", encoding="utf-8") as log:
|
with log_path.open("a", encoding="utf-8") as log:
|
||||||
|
|||||||
@@ -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.16.5">
|
<link rel="stylesheet" href="/static/style.css?v=0.17.0">
|
||||||
</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.16.5</span></h1>
|
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.17.0</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.16.5"></script>
|
<script src="/static/app.js?v=0.17.0"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).with_name("server.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH)
|
||||||
|
server = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
SPEC.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
class FullBookTranslationTests(unittest.TestCase):
|
||||||
|
def test_default_model_targets_gpt_5_6_sol(self):
|
||||||
|
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-5.6-sol")
|
||||||
|
|
||||||
|
def test_chunk_plan_keeps_file_boundaries(self):
|
||||||
|
items = [
|
||||||
|
{"id": "T00001", "file": "a.xhtml", "document_title": "A", "source_text": "一" * 17},
|
||||||
|
{"id": "T00002", "file": "a.xhtml", "document_title": "A", "source_text": "二" * 17},
|
||||||
|
{"id": "T00003", "file": "b.xhtml", "document_title": "B", "source_text": "三" * 17},
|
||||||
|
]
|
||||||
|
chunks = server.build_translation_plan(items, chunk_target=2)
|
||||||
|
self.assertEqual([chunk["item_count"] for chunk in chunks], [2, 1])
|
||||||
|
self.assertEqual([chunk["file"] for chunk in chunks], ["a.xhtml", "b.xhtml"])
|
||||||
|
self.assertEqual(chunks[0]["first_item_id"], "T00001")
|
||||||
|
self.assertEqual(chunks[0]["last_item_id"], "T00002")
|
||||||
|
|
||||||
|
def test_glossary_roundtrip_keeps_legacy_object_shape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
glossary_file = root / "mingcibiao.json"
|
||||||
|
server.write_json(root / "gpt_config.json", {"glossary_path": str(glossary_file)})
|
||||||
|
payload = server.save_glossary(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
{"source": "勇者", "target": "勇者"},
|
||||||
|
{"source": "聖剣", "target": "圣剑(Excalibur)#保留有意义注音"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
saved = server.read_json(glossary_file, {})
|
||||||
|
self.assertEqual(saved["聖剣"], "圣剑(Excalibur)#保留有意义注音")
|
||||||
|
self.assertEqual(payload["count"], 2)
|
||||||
|
self.assertTrue(all(isinstance(value, str) for value in saved.values()))
|
||||||
|
|
||||||
|
def test_job_list_never_exposes_api_key(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
job = {
|
||||||
|
"id": "tr_test",
|
||||||
|
"status": "running",
|
||||||
|
"source_session_id": "book",
|
||||||
|
"settings": {"api_key": "secret-value", "model": "gpt-5.6-sol"},
|
||||||
|
"logs": [],
|
||||||
|
}
|
||||||
|
server.write_translation_job(root, job)
|
||||||
|
public = server.list_translation_jobs(root)
|
||||||
|
self.assertNotIn("secret-value", str(public))
|
||||||
|
|
||||||
|
def test_library_translation_status_is_derived_from_jobs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
session = root / "book_session"
|
||||||
|
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||||
|
server.write_json(
|
||||||
|
server.session_manifest_path(session),
|
||||||
|
{"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}},
|
||||||
|
)
|
||||||
|
server.write_translation_job(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"id": "tr_status",
|
||||||
|
"status": "running",
|
||||||
|
"source_session_id": session.name,
|
||||||
|
"settings": {},
|
||||||
|
"logs": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
library = server.build_library_index(root)
|
||||||
|
self.assertEqual(library["sessions"][0]["translation_status"], "translating")
|
||||||
|
|
||||||
|
def test_manual_series_assignment_survives_library_rebuild(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
session = root / "book_session"
|
||||||
|
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||||
|
server.write_json(
|
||||||
|
server.session_manifest_path(session),
|
||||||
|
{
|
||||||
|
"id": session.name,
|
||||||
|
"source_name": "book.epub",
|
||||||
|
"source_epub": "book.epub",
|
||||||
|
"metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"})
|
||||||
|
library = server.save_library_series_overrides(
|
||||||
|
root,
|
||||||
|
{"action": "assign", "session_id": session.name, "series_id": "manual"},
|
||||||
|
)
|
||||||
|
book = next(item for item in library["sessions"] if item["id"] == session.name)
|
||||||
|
self.assertEqual(book["series_id"], "manual")
|
||||||
|
self.assertEqual(book["series"], "手动系列")
|
||||||
|
rebuilt = server.build_library_index(root)
|
||||||
|
rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name)
|
||||||
|
self.assertEqual(rebuilt_book["series_id"], "manual")
|
||||||
|
|
||||||
|
def test_models_endpoint_returns_only_public_ids(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = server.create_app(Path(temp_dir))
|
||||||
|
with patch.object(server, "read_scoped_gpt_config", return_value={
|
||||||
|
"api_key": "secret-value",
|
||||||
|
"base_url": "https://example.test/v1",
|
||||||
|
"model": "gpt-5.6-sol",
|
||||||
|
}), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]):
|
||||||
|
response = app.test_client().get("/api/gpt/models")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body["models"], ["gpt-5.6-sol"])
|
||||||
|
self.assertNotIn("secret-value", str(body))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version = "0.16.5"
|
version = "0.17.0"
|
||||||
|
|
||||||
VERSION_RULES = {
|
VERSION_RULES = {
|
||||||
"PATCH": "修复 bug 或兼容性小修",
|
"PATCH": "修复 bug 或兼容性小修",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Remote = "akai-tools",
|
[string]$Remote = "origin",
|
||||||
[string]$Branch = "codex/desktop-review-editor-blocks",
|
[string]$Branch = "codex/full-book-translation",
|
||||||
|
[switch]$Web,
|
||||||
[switch]$SkipPull,
|
[switch]$SkipPull,
|
||||||
[switch]$CheckOnly,
|
[switch]$CheckOnly,
|
||||||
[switch]$KeepRunning
|
[switch]$KeepRunning
|
||||||
@@ -64,6 +65,24 @@ 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) {
|
||||||
@@ -123,7 +142,24 @@ try {
|
|||||||
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||||
|
|
||||||
Write-Step "Updating submodules"
|
Write-Step "Updating submodules"
|
||||||
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
|
$requiredSubmodules = @(
|
||||||
|
"packages/foliate-js",
|
||||||
|
"packages/simplecc-wasm",
|
||||||
|
"packages/js-mdict",
|
||||||
|
"apps/readest-app/src-tauri/plugins/tauri-plugin-turso"
|
||||||
|
)
|
||||||
|
$missingSubmodules = @($requiredSubmodules | Where-Object { -not (Test-SubmoduleCheckout $_) })
|
||||||
|
if ($missingSubmodules.Count -gt 0) {
|
||||||
|
$submoduleArgs = @(
|
||||||
|
"-C", $RepoRoot,
|
||||||
|
"-c", "http.version=HTTP/1.1",
|
||||||
|
"-c", "submodule.fetchJobs=1",
|
||||||
|
"submodule", "update", "--init", "--depth", "1", "--"
|
||||||
|
) + $missingSubmodules
|
||||||
|
Invoke-Checked "git" $submoduleArgs
|
||||||
|
} else {
|
||||||
|
Write-Host "Required submodules are already initialized."
|
||||||
|
}
|
||||||
|
|
||||||
$changedFiles = @()
|
$changedFiles = @()
|
||||||
if ($beforeHead -ne $afterHead) {
|
if ($beforeHead -ne $afterHead) {
|
||||||
@@ -141,6 +177,23 @@ 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
|
||||||
@@ -148,9 +201,13 @@ try {
|
|||||||
|
|
||||||
Stop-OldReadestDev
|
Stop-OldReadestDev
|
||||||
|
|
||||||
Write-Step "Starting Readest desktop with the latest code"
|
if ($Web) {
|
||||||
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
|
Start-ReadestWeb
|
||||||
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
|
} else {
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user