forked from akai/readest
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4136c53c7 | |||
| 03c6969ce6 | |||
| c3a3a1d035 | |||
| 0d0732e579 | |||
| 3938c9c8bd | |||
| 64508fd336 | |||
| a06b6183d7 | |||
| 43ce37c61b | |||
| 545c56966f | |||
| 2d97de18c1 |
@@ -34,6 +34,10 @@ test.describe('Inline review mode', () => {
|
||||
|
||||
const panel = page.getByRole('group', { name: '审校' });
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
await panel.getByRole('tab', { name: '整书翻译' }).click();
|
||||
await expect(panel.getByRole('heading', { name: '整书翻译' })).toBeVisible();
|
||||
await expect(panel.getByText(/个正文块/)).toBeVisible({ timeout: 30_000 });
|
||||
await panel.getByRole('tab', { name: '逐段审校' }).click();
|
||||
const editor = panel.locator('textarea').first();
|
||||
await expect(editor).toHaveValue('这是网页审校测试译文。');
|
||||
await editor.fill('这是已保存的网页审校测试译文。');
|
||||
@@ -48,9 +52,7 @@ test.describe('Inline review mode', () => {
|
||||
await page.getByRole('button', { name: 'Review Mode' }).click();
|
||||
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
await expect(panel.locator('textarea').first()).toHaveValue(
|
||||
'这是已保存的网页审校测试译文。',
|
||||
);
|
||||
await expect(panel.locator('textarea').first()).toHaveValue('这是已保存的网页审校测试译文。');
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await panel.getByRole('button', { name: '导出 EPUB' }).click();
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-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-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": "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-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": "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-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",
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
generateReviewFeedback,
|
||||
isReviewEditDirty,
|
||||
launchInlineReviewEditor,
|
||||
listTranslationJobs,
|
||||
loadTranslationPlan,
|
||||
controlTranslationJob,
|
||||
loadInlineReviewData,
|
||||
loadReviewGlossary,
|
||||
saveReviewGptConfig,
|
||||
@@ -53,10 +56,7 @@ describe('reviewEditorService', () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json(
|
||||
{ ok: false, error: '本地 Web 审校入口未启用' },
|
||||
{ status: 404 },
|
||||
),
|
||||
Response.json({ ok: false, error: '本地 Web 审校入口未启用' }, { status: 404 }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -117,8 +117,8 @@ describe('reviewEditorService', () => {
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
|
||||
const fetchMock = vi.fn(
|
||||
async () => new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
|
||||
@@ -194,13 +194,7 @@ describe('reviewEditorService', () => {
|
||||
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await sidecarApi(
|
||||
'http://127.0.0.1:5177',
|
||||
'/api/rows',
|
||||
{},
|
||||
'session-a',
|
||||
'sidecar-token',
|
||||
);
|
||||
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a', 'sidecar-token');
|
||||
|
||||
const url = new URL(firstFetchUrl(fetchMock));
|
||||
expect(url.pathname).toBe('/api/rows');
|
||||
@@ -283,4 +277,28 @@ describe('reviewEditorService', () => {
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
}
|
||||
});
|
||||
|
||||
test('translation APIs keep bearer auth and current-book session scope', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await loadTranslationPlan('http://127.0.0.1:5177', 'session-a', 24, 'sidecar-token');
|
||||
await listTranslationJobs('http://127.0.0.1:5177', 'session-a', 'sidecar-token');
|
||||
await controlTranslationJob(
|
||||
'http://127.0.0.1:5177',
|
||||
'session-a',
|
||||
'tr_1',
|
||||
'pause',
|
||||
'sidecar-token',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
for (const call of fetchMock.mock.calls as unknown as [
|
||||
string | URL,
|
||||
RequestInit | undefined,
|
||||
][]) {
|
||||
const url = new URL(call[0].toString());
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
expect(call[1]?.headers).toMatchObject({ Authorization: 'Bearer sidecar-token' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Download, Pause, Play, RefreshCw, Square, Sparkles } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
controlTranslationJob,
|
||||
downloadReviewedEpub,
|
||||
listTranslationJobs,
|
||||
loadTranslationDefaults,
|
||||
loadTranslationJob,
|
||||
loadTranslationPlan,
|
||||
loadTranslationSource,
|
||||
startFullBookTranslation,
|
||||
type TranslationJobSummary,
|
||||
type TranslationPlanPayload,
|
||||
type TranslationSourcePayload,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
type TranslationSettings = {
|
||||
output_mode: 'bilingual' | 'translated';
|
||||
range_mode: 'all' | 'limit';
|
||||
limit: number;
|
||||
temperature: number;
|
||||
chunk_target: number;
|
||||
};
|
||||
|
||||
const defaultSettings: TranslationSettings = {
|
||||
output_mode: 'bilingual',
|
||||
range_mode: 'limit',
|
||||
limit: 20,
|
||||
temperature: 0.2,
|
||||
chunk_target: 24,
|
||||
};
|
||||
|
||||
const isRunningJob = (job: TranslationJobSummary | null) =>
|
||||
job ? ['pending', 'running'].includes(job.status) : false;
|
||||
|
||||
const blocksNewJob = (job: TranslationJobSummary | null) =>
|
||||
job ? ['pending', 'running', 'paused'].includes(job.status) : false;
|
||||
|
||||
const ActionButton = ({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-3'
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const FullBookTranslationPanel = ({
|
||||
baseUrl,
|
||||
sessionId,
|
||||
accessToken,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
const [settings, setSettings] = useState(defaultSettings);
|
||||
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
|
||||
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
|
||||
const [job, setJob] = useState<TranslationJobSummary | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const pollRef = useRef<number | null>(null);
|
||||
const defaultsAppliedRef = useRef(false);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current !== null) window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}, []);
|
||||
|
||||
const refreshJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
const payload = await loadTranslationJob(baseUrl, sessionId, jobId, accessToken);
|
||||
setJob(payload.job);
|
||||
if (!isRunningJob(payload.job)) stopPolling();
|
||||
},
|
||||
[accessToken, baseUrl, sessionId, stopPolling],
|
||||
);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(jobId: string) => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void refreshJob(jobId).catch((error) => {
|
||||
stopPolling();
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 1200);
|
||||
},
|
||||
[refreshJob, stopPolling],
|
||||
);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const [sourcePayload, defaultsPayload, planPayload, jobsPayload] = await Promise.all([
|
||||
loadTranslationSource(baseUrl, sessionId, accessToken),
|
||||
loadTranslationDefaults(baseUrl, sessionId, accessToken),
|
||||
loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken),
|
||||
listTranslationJobs(baseUrl, sessionId, accessToken),
|
||||
]);
|
||||
setSource(sourcePayload);
|
||||
setPlan(planPayload);
|
||||
if (defaultsPayload.defaults && !defaultsAppliedRef.current) {
|
||||
defaultsAppliedRef.current = true;
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
output_mode: defaultsPayload.defaults?.output_mode || current.output_mode,
|
||||
range_mode: defaultsPayload.defaults?.range_mode || current.range_mode,
|
||||
limit: defaultsPayload.defaults?.limit || current.limit,
|
||||
temperature: defaultsPayload.defaults?.temperature ?? current.temperature,
|
||||
chunk_target: defaultsPayload.defaults?.chunk_target || current.chunk_target,
|
||||
}));
|
||||
}
|
||||
const latest = jobsPayload.jobs[0] || null;
|
||||
setJob(latest);
|
||||
if (latest && isRunningJob(latest)) startPolling(latest.id);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [accessToken, baseUrl, sessionId, settings.chunk_target, startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
return stopPolling;
|
||||
}, [loadOverview, stopPolling]);
|
||||
|
||||
const refreshPlan = async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
setPlan(await loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken));
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
if (
|
||||
settings.range_mode === 'all' &&
|
||||
!window.confirm('整书翻译会连续调用已配置的 GPT API。确认开始吗?')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await startFullBookTranslation(baseUrl, sessionId, settings, accessToken);
|
||||
setJob(payload.job);
|
||||
startPolling(payload.job.id);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const control = async (action: 'pause' | 'resume' | 'cancel') => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await controlTranslationJob(baseUrl, sessionId, job.id, action, accessToken);
|
||||
setJob(payload.job);
|
||||
startPolling(job.id);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const outputUrl = job?.output_download_url
|
||||
? new URL(job.output_download_url, baseUrl).toString()
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className='grid gap-3'>
|
||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-sm font-semibold'>整书翻译</h3>
|
||||
<p className='text-base-content/60 text-xs'>
|
||||
{source ? `${source.source_count} 个正文块` : '正在读取翻译源'}
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton label='刷新翻译状态' onClick={() => void loadOverview()} disabled={busy}>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
输出格式
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={settings.output_mode}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
output_mode: event.target.value as TranslationSettings['output_mode'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value='bilingual'>中日双语</option>
|
||||
<option value='translated'>纯中文</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
翻译范围
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={settings.range_mode}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
range_mode: event.target.value as TranslationSettings['range_mode'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value='limit'>试译</option>
|
||||
<option value='all'>全书</option>
|
||||
</select>
|
||||
</label>
|
||||
{settings.range_mode === 'limit' ? (
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
试译段数
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
type='number'
|
||||
min={1}
|
||||
max={5000}
|
||||
value={settings.limit}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({ ...current, limit: Number(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
计划分组大小
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
type='number'
|
||||
min={1}
|
||||
max={120}
|
||||
value={settings.chunk_target}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
chunk_target: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
onBlur={() => void refreshPlan()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{plan ? (
|
||||
<p className='text-base-content/70 text-xs'>
|
||||
{plan.chunk_count} 个进度分组 · 约 {plan.estimated_input_tokens.toLocaleString()} 输入
|
||||
token · 不跨 XHTML 文件
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-primary h-auto min-h-9 rounded-md px-3 py-2 text-sm'
|
||||
onClick={() => void start()}
|
||||
disabled={busy || blocksNewJob(job) || !source?.source_count}
|
||||
>
|
||||
<Sparkles className='h-4 w-4' />
|
||||
{settings.range_mode === 'all' ? '开始整书翻译' : '开始试译'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{job ? (
|
||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-sm font-semibold'>任务 {job.id}</h3>
|
||||
<p className='text-base-content/60 text-xs break-words'>
|
||||
{job.status} · {job.progress?.current || 0}/{job.progress?.total || 0}
|
||||
</p>
|
||||
</div>
|
||||
<span className='text-sm font-semibold'>{job.progress?.percent || 0}%</span>
|
||||
</div>
|
||||
<progress
|
||||
className='progress progress-primary h-2 w-full'
|
||||
value={job.progress?.percent || 0}
|
||||
max={100}
|
||||
/>
|
||||
{job.progress?.message ? (
|
||||
<p className='text-base-content/70 text-xs break-words'>{job.progress.message}</p>
|
||||
) : null}
|
||||
{job.error ? <p className='text-error text-xs break-words'>{job.error}</p> : null}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{job.status === 'running' || job.status === 'pending' ? (
|
||||
<ActionButton label='暂停翻译' onClick={() => void control('pause')} disabled={busy}>
|
||||
<Pause className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{['paused', 'failed', 'cancelled'].includes(job.status) ? (
|
||||
<ActionButton
|
||||
label='从断点恢复'
|
||||
onClick={() => void control('resume')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Play className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{!['completed', 'failed', 'cancelled'].includes(job.status) ? (
|
||||
<ActionButton label='取消翻译' onClick={() => void control('cancel')} disabled={busy}>
|
||||
<Square className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{outputUrl ? (
|
||||
<ActionButton
|
||||
label='下载翻译 EPUB'
|
||||
onClick={() => void downloadReviewedEpub(outputUrl, accessToken)}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||
role='status'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullBookTranslationPanel;
|
||||
@@ -27,6 +27,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { getPanelTopInset } from '@/utils/insets';
|
||||
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
|
||||
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
|
||||
import FullBookTranslationPanel from './FullBookTranslationPanel';
|
||||
import {
|
||||
downloadReviewedEpub,
|
||||
exportReviewedEpub,
|
||||
@@ -512,6 +513,7 @@ const ReviewPanel: React.FC = () => {
|
||||
const [retranslating, setRetranslating] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<'review' | 'translate'>('review');
|
||||
const isMobile = useIsMobileViewport();
|
||||
const panelTopInset = getPanelTopInset({
|
||||
isMobile,
|
||||
@@ -934,6 +936,28 @@ const ReviewPanel: React.FC = () => {
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto p-3'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='join grid grid-cols-2' role='tablist' aria-label='审校器功能'>
|
||||
<button
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={panelMode === 'review'}
|
||||
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'review' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
|
||||
onClick={() => setPanelMode('review')}
|
||||
>
|
||||
逐段审校
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={panelMode === 'translate'}
|
||||
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'translate' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
|
||||
disabled={hasUnsavedEdit}
|
||||
title={hasUnsavedEdit ? '请先保存当前段落' : '切换到整书翻译'}
|
||||
onClick={() => setPanelMode('translate')}
|
||||
>
|
||||
整书翻译
|
||||
</button>
|
||||
</div>
|
||||
{bookState.loading ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||
正在启动审校器并读取当前 EPUB……
|
||||
@@ -948,261 +972,280 @@ const ReviewPanel: React.FC = () => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PanelButton
|
||||
onClick={generateFeedback}
|
||||
icon={<FileText className='h-4 w-4' />}
|
||||
disabled={!baseUrl || hasUnsavedEdit}
|
||||
>
|
||||
生成反馈
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={exportEpub}
|
||||
icon={<Download className='h-4 w-4' />}
|
||||
disabled={!baseUrl || exporting || hasUnsavedEdit}
|
||||
variant='primary'
|
||||
>
|
||||
{exporting ? '导出中' : '导出 EPUB'}
|
||||
</PanelButton>
|
||||
</div>
|
||||
{hasUnsavedEdit ? (
|
||||
<p className='text-base-content/70 text-xs'>请先保存当前段落,再生成反馈或导出。</p>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
{message}
|
||||
{panelMode === 'translate' && baseUrl && sessionId ? (
|
||||
<div role='tabpanel' aria-label='整书翻译'>
|
||||
<FullBookTranslationPanel
|
||||
baseUrl={baseUrl}
|
||||
sessionId={sessionId}
|
||||
accessToken={bookState.accessToken}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{exportInfo ? (
|
||||
<button
|
||||
type='button'
|
||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
|
||||
>
|
||||
<span className='font-semibold'>重新下载导出文件</span>
|
||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||
{exportInfo}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!selectedRow ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
|
||||
{rows.length
|
||||
? '请选择一段开始审校。'
|
||||
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
|
||||
<span className='min-w-0 break-words'>
|
||||
{selectedRow.id} · {rowTitle(selectedRow)}
|
||||
</span>
|
||||
<span className='shrink-0 break-words'>
|
||||
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className='prose prose-sm max-w-none select-text leading-8 break-words'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className='grid gap-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<PanelButton
|
||||
onClick={insertRuby}
|
||||
disabled={!canUseRowActions}
|
||||
icon={<Languages className='h-4 w-4' />}
|
||||
>
|
||||
注音
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={insertAnnotation}
|
||||
disabled={!canUseRowActions}
|
||||
icon={<MessageSquarePlus className='h-4 w-4' />}
|
||||
>
|
||||
注释
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={saveCurrentRow}
|
||||
disabled={!canUseRowActions || saving}
|
||||
icon={<Save className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
{saving ? '保存中' : '保存'}
|
||||
</PanelButton>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={cnEditorRef}
|
||||
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
|
||||
value={edit.current_html}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, current_html: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center justify-between gap-2 text-start'
|
||||
onClick={() => setPreviewOpen((value) => !value)}
|
||||
>
|
||||
<span className='text-base-content/60 text-xs'>译文预览</span>
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{previewOpen ? '收起' : '展开'}
|
||||
</span>
|
||||
</button>
|
||||
{previewOpen ? (
|
||||
<div
|
||||
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<h4 className='text-sm font-semibold'>问题记录</h4>
|
||||
<p className='text-base-content/60 mt-1 text-xs'>
|
||||
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex shrink-0 items-center gap-2 text-xs'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={edit.marked}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, marked: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
纳入反馈
|
||||
</label>
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
问题类型
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={edit.issue_type}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({
|
||||
...current,
|
||||
issue_type: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value=''>未选择</option>
|
||||
<option value='误译'>误译</option>
|
||||
<option value='漏译'>漏译</option>
|
||||
<option value='术语不一致'>术语不一致</option>
|
||||
<option value='人名/地名问题'>人名/地名问题</option>
|
||||
<option value='翻译腔'>翻译腔</option>
|
||||
<option value='角色口吻不对'>角色口吻不对</option>
|
||||
<option value='语序不顺'>语序不顺</option>
|
||||
<option value='标点/格式'>标点/格式</option>
|
||||
<option value='其他'>其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
严重程度
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={edit.severity}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, severity: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value=''>未选择</option>
|
||||
<option value='轻微'>轻微</option>
|
||||
<option value='中等'>中等</option>
|
||||
<option value='严重'>严重</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
标签
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
value={edit.tags}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, tags: event.target.value }))
|
||||
}
|
||||
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
本段备注
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={edit.comment}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, comment: event.target.value }))
|
||||
}
|
||||
placeholder='只记录当前段哪里有问题、为什么这样改'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
长期规则建议
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={edit.learn_note}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
||||
}
|
||||
placeholder='只写后续段落也应遵守的规则或口吻偏好'
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className='border-base-300 grid gap-3 border-t pt-3'>
|
||||
<h3 className='text-sm font-semibold'>API 重翻</h3>
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={retranslateInstruction}
|
||||
onChange={(event) => setRetranslateInstruction(event.target.value)}
|
||||
placeholder='额外要求,可留空'
|
||||
/>
|
||||
{panelMode === 'review' ? (
|
||||
<div className='contents' role='tabpanel' aria-label='逐段审校'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PanelButton
|
||||
onClick={retranslateCurrentRow}
|
||||
disabled={!canUseRowActions || retranslating}
|
||||
icon={<Sparkles className='h-4 w-4' />}
|
||||
onClick={generateFeedback}
|
||||
icon={<FileText className='h-4 w-4' />}
|
||||
disabled={!baseUrl || hasUnsavedEdit}
|
||||
>
|
||||
{retranslating ? '重翻中' : '重翻本段'}
|
||||
生成反馈
|
||||
</PanelButton>
|
||||
{candidateHtml ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<div
|
||||
className='prose prose-sm max-w-none select-text leading-7 break-words'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
<PanelButton
|
||||
onClick={() =>
|
||||
setEdit((current) => ({ ...current, current_html: candidateHtml }))
|
||||
}
|
||||
icon={<Check className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
应用候选到编辑框
|
||||
</PanelButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<PanelButton
|
||||
onClick={exportEpub}
|
||||
icon={<Download className='h-4 w-4' />}
|
||||
disabled={!baseUrl || exporting || hasUnsavedEdit}
|
||||
variant='primary'
|
||||
>
|
||||
{exporting ? '导出中' : '导出 EPUB'}
|
||||
</PanelButton>
|
||||
</div>
|
||||
{hasUnsavedEdit ? (
|
||||
<p className='text-base-content/70 text-xs'>
|
||||
请先保存当前段落,再生成反馈或导出。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
{exportInfo ? (
|
||||
<button
|
||||
type='button'
|
||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
|
||||
>
|
||||
<span className='font-semibold'>重新下载导出文件</span>
|
||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||
{exportInfo}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!selectedRow ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
|
||||
{rows.length
|
||||
? '请选择一段开始审校。'
|
||||
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
|
||||
<span className='min-w-0 break-words'>
|
||||
{selectedRow.id} · {rowTitle(selectedRow)}
|
||||
</span>
|
||||
<span className='shrink-0 break-words'>
|
||||
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className='prose prose-sm max-w-none select-text leading-8 break-words'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeInlineHtml(selectedRow.jp_html),
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className='grid gap-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<PanelButton
|
||||
onClick={insertRuby}
|
||||
disabled={!canUseRowActions}
|
||||
icon={<Languages className='h-4 w-4' />}
|
||||
>
|
||||
注音
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={insertAnnotation}
|
||||
disabled={!canUseRowActions}
|
||||
icon={<MessageSquarePlus className='h-4 w-4' />}
|
||||
>
|
||||
注释
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={saveCurrentRow}
|
||||
disabled={!canUseRowActions || saving}
|
||||
icon={<Save className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
{saving ? '保存中' : '保存'}
|
||||
</PanelButton>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={cnEditorRef}
|
||||
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
|
||||
value={edit.current_html}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, current_html: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center justify-between gap-2 text-start'
|
||||
onClick={() => setPreviewOpen((value) => !value)}
|
||||
>
|
||||
<span className='text-base-content/60 text-xs'>译文预览</span>
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{previewOpen ? '收起' : '展开'}
|
||||
</span>
|
||||
</button>
|
||||
{previewOpen ? (
|
||||
<div
|
||||
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeInlineHtml(edit.current_html),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<h4 className='text-sm font-semibold'>问题记录</h4>
|
||||
<p className='text-base-content/60 mt-1 text-xs'>
|
||||
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex shrink-0 items-center gap-2 text-xs'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={edit.marked}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, marked: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
纳入反馈
|
||||
</label>
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
问题类型
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={edit.issue_type}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({
|
||||
...current,
|
||||
issue_type: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value=''>未选择</option>
|
||||
<option value='误译'>误译</option>
|
||||
<option value='漏译'>漏译</option>
|
||||
<option value='术语不一致'>术语不一致</option>
|
||||
<option value='人名/地名问题'>人名/地名问题</option>
|
||||
<option value='翻译腔'>翻译腔</option>
|
||||
<option value='角色口吻不对'>角色口吻不对</option>
|
||||
<option value='语序不顺'>语序不顺</option>
|
||||
<option value='标点/格式'>标点/格式</option>
|
||||
<option value='其他'>其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
严重程度
|
||||
<select
|
||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||
value={edit.severity}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, severity: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value=''>未选择</option>
|
||||
<option value='轻微'>轻微</option>
|
||||
<option value='中等'>中等</option>
|
||||
<option value='严重'>严重</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
标签
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
value={edit.tags}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, tags: event.target.value }))
|
||||
}
|
||||
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
本段备注
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={edit.comment}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, comment: event.target.value }))
|
||||
}
|
||||
placeholder='只记录当前段哪里有问题、为什么这样改'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
长期规则建议
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={edit.learn_note}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
||||
}
|
||||
placeholder='只写后续段落也应遵守的规则或口吻偏好'
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className='border-base-300 grid gap-3 border-t pt-3'>
|
||||
<h3 className='text-sm font-semibold'>API 重翻</h3>
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||
value={retranslateInstruction}
|
||||
onChange={(event) => setRetranslateInstruction(event.target.value)}
|
||||
placeholder='额外要求,可留空'
|
||||
/>
|
||||
<PanelButton
|
||||
onClick={retranslateCurrentRow}
|
||||
disabled={!canUseRowActions || retranslating}
|
||||
icon={<Sparkles className='h-4 w-4' />}
|
||||
>
|
||||
{retranslating ? '重翻中' : '重翻本段'}
|
||||
</PanelButton>
|
||||
{candidateHtml ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||
<div
|
||||
className='prose prose-sm max-w-none select-text leading-7 break-words'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
<PanelButton
|
||||
onClick={() =>
|
||||
setEdit((current) => ({ ...current, current_html: candidateHtml }))
|
||||
}
|
||||
icon={<Check className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
应用候选到编辑框
|
||||
</PanelButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<GptConfigPanel
|
||||
baseUrl={baseUrl}
|
||||
sessionId={sessionId}
|
||||
|
||||
@@ -120,6 +120,62 @@ export type ReviewGlossaryEntry = {
|
||||
clean_target?: string;
|
||||
};
|
||||
|
||||
export type TranslationChunkPlan = {
|
||||
id: string;
|
||||
file: string;
|
||||
title?: string;
|
||||
first_item_id: string;
|
||||
last_item_id: string;
|
||||
item_count: number;
|
||||
source_characters: number;
|
||||
estimated_input_tokens: number;
|
||||
};
|
||||
|
||||
export type TranslationPlanPayload = {
|
||||
session_id: string;
|
||||
source_count: number;
|
||||
chunk_target: number;
|
||||
chunk_count: number;
|
||||
estimated_input_tokens: number;
|
||||
chunks: TranslationChunkPlan[];
|
||||
};
|
||||
|
||||
export type TranslationJobSummary = {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
source_session_id: string;
|
||||
source_name?: string;
|
||||
progress?: {
|
||||
current?: number;
|
||||
total?: number;
|
||||
percent?: number;
|
||||
failed?: number;
|
||||
message?: string;
|
||||
};
|
||||
output_epub?: string;
|
||||
output_download_url?: string;
|
||||
output_session_id?: string;
|
||||
error?: string;
|
||||
logs?: { ts?: string; level?: string; message?: string }[];
|
||||
};
|
||||
|
||||
export type TranslationDefaultsPayload = {
|
||||
gpt?: ReviewGptConfig;
|
||||
defaults?: {
|
||||
output_mode?: 'bilingual' | 'translated';
|
||||
range_mode?: 'all' | 'limit';
|
||||
limit?: number;
|
||||
temperature?: number;
|
||||
chunk_target?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TranslationSourcePayload = {
|
||||
source_count: number;
|
||||
files?: { file: string; count: number }[];
|
||||
stats?: { note?: string };
|
||||
};
|
||||
|
||||
export type ReviewGlossaryPayload = {
|
||||
path?: string;
|
||||
exists?: boolean;
|
||||
@@ -152,8 +208,7 @@ const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
|
||||
|
||||
export const canLaunchInlineReviewEditor = (
|
||||
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
|
||||
localWebDevelopment =
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
|
||||
localWebDevelopment = process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
|
||||
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
|
||||
|
||||
export async function sidecarApi<T>(
|
||||
@@ -168,8 +223,9 @@ export async function sidecarApi<T>(
|
||||
url.searchParams.set('session_id', sessionId);
|
||||
}
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||
const headers = {
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(options.body === undefined || isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
@@ -413,10 +469,7 @@ export async function exportReviewedEpub(
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadReviewedEpub(
|
||||
downloadUrl: string,
|
||||
accessToken = '',
|
||||
): Promise<void> {
|
||||
export async function downloadReviewedEpub(downloadUrl: string, accessToken = ''): Promise<void> {
|
||||
const url = new URL(downloadUrl);
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(url.origin));
|
||||
if (!resolvedAccessToken) throw new Error('审校器访问令牌不可用');
|
||||
@@ -434,3 +487,112 @@ export async function downloadReviewedEpub(
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
export async function loadTranslationSource(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
accessToken?: string,
|
||||
): Promise<TranslationSourcePayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
|
||||
{},
|
||||
sessionId,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadTranslationDefaults(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
accessToken?: string,
|
||||
): Promise<TranslationDefaultsPayload> {
|
||||
return sidecarApi(baseUrl, '/api/translation/defaults', {}, sessionId, accessToken);
|
||||
}
|
||||
|
||||
export async function loadTranslationPlan(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
chunkTarget: number,
|
||||
accessToken?: string,
|
||||
): Promise<TranslationPlanPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/session/${encodeURIComponent(sessionId)}/translation-plan`,
|
||||
{ method: 'POST', body: JSON.stringify({ chunk_target: chunkTarget }) },
|
||||
sessionId,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startFullBookTranslation(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
settings: {
|
||||
output_mode: 'bilingual' | 'translated';
|
||||
range_mode: 'all' | 'limit';
|
||||
limit: number;
|
||||
temperature: number;
|
||||
chunk_target: number;
|
||||
},
|
||||
accessToken?: string,
|
||||
): Promise<{ job: TranslationJobSummary }> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/translation/start',
|
||||
{ method: 'POST', body: JSON.stringify({ session_id: sessionId, ...settings }) },
|
||||
sessionId,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTranslationJobs(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
accessToken?: string,
|
||||
): Promise<{ jobs: TranslationJobSummary[]; count: number }> {
|
||||
return sidecarApi(baseUrl, '/api/translation/jobs', {}, sessionId, accessToken);
|
||||
}
|
||||
|
||||
export async function loadTranslationJob(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
jobId: string,
|
||||
accessToken?: string,
|
||||
): Promise<{ job: TranslationJobSummary }> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
|
||||
{},
|
||||
sessionId,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function controlTranslationJob(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
jobId: string,
|
||||
action: 'pause' | 'resume' | 'cancel',
|
||||
accessToken?: string,
|
||||
): Promise<{ job: TranslationJobSummary }> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
sessionId,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeTranslationModels(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
accessToken?: string,
|
||||
): Promise<{
|
||||
models: string[];
|
||||
configured_model: string;
|
||||
configured_model_available: boolean;
|
||||
}> {
|
||||
return sidecarApi(baseUrl, '/api/gpt/models', {}, sessionId, accessToken);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- `tools/epub-review-editor/static/` 独立网页
|
||||
- `open_editor.ps1` 或 `epub-reviewer:dev` 独立启动链路
|
||||
|
||||
整书翻译、独立审校书架、独立目录/检索页面等功能不属于内嵌审校模式,不应迁入阅读页。
|
||||
整书翻译是阅读页内嵌审校面板的第二个页签,可复用当前书籍 session、GPT 配置和术语表。独立审校书架、独立目录/检索页面、旧 static 网页和 `/review-editor` 仍不属于内嵌模式,不得恢复。
|
||||
|
||||
## 必须保留的链路
|
||||
|
||||
@@ -71,6 +71,9 @@ Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`)
|
||||
- `/api/session/<session_id>/translation-source` 与 `/translation-plan`
|
||||
- `/api/translation/defaults`、`/start`、`/jobs` 及任务控制端点
|
||||
- `/api/gpt/models` 与 `/downloads/translation/...`
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
@@ -110,7 +113,7 @@ sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 l
|
||||
- MINOR:向后兼容的能力或 API 扩展
|
||||
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
||||
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。`2.1.0` 在当前安全契约上新增内嵌整书翻译、计划、任务控制、断点续跑和安全输出下载,翻译状态为 `untranslated`、`translating`、`translated` 或 `failed`。
|
||||
|
||||
## 回归清单
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Readest 内嵌 EPUB 审校后端
|
||||
|
||||
当前版本:`2.0.0`
|
||||
当前版本:`2.1.0`
|
||||
|
||||
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
`server.py`、`version.py` 和 `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。
|
||||
|
||||
阅读页右侧面板提供“逐段审校 / 整书翻译”两个页签。整书翻译支持保持 XHTML 文件边界的计划预览、试译或全书任务、断点、暂停、恢复、取消和带令牌的输出下载;旧独立网页、独立书架和 `/review-editor` 不会恢复。
|
||||
|
||||
## 使用方式
|
||||
|
||||
桌面端:
|
||||
@@ -75,6 +77,15 @@ sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web`,multipart,`activate=false`,携带稳定 `book_key` 复用会话)
|
||||
- `GET /api/session/<session_id>/translation-source?session_id=...`
|
||||
- `POST /api/session/<session_id>/translation-plan?session_id=...`
|
||||
- `GET /api/translation/defaults?session_id=...`
|
||||
- `POST /api/translation/start?session_id=...`
|
||||
- `GET /api/translation/jobs?session_id=...`
|
||||
- `GET|POST /api/translation/jobs/<job_id>[/<action>]?session_id=...`
|
||||
- `GET /api/gpt/models?session_id=...`
|
||||
|
||||
翻译状态为 `untranslated`、`translating`、`translated` 或 `failed`。输出下载必须继续携带 Bearer 令牌和源书 `session_id`,公开响应不返回 API Key。
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
@@ -93,3 +104,4 @@ sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令
|
||||
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
|
||||
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
|
||||
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
|
||||
- `2.1.0`:在 Readest 阅读页内嵌整书翻译页签,新增翻译计划、任务列表、断点续跑、暂停/恢复/取消、模型探测和安全输出下载;默认开发基座改为本地 `dev-web`,保留 Tauri 兼容。
|
||||
|
||||
@@ -104,6 +104,8 @@ TRANSLATION_LOG_LIMIT = 80
|
||||
DEFAULT_TRANSLATION_LIMIT = 20
|
||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||
TRANSLATION_CONTEXT_RADIUS = 1
|
||||
DEFAULT_TRANSLATION_CHUNK_TARGET = 24
|
||||
TRANSLATION_CHUNK_TARGET_MAX = 120
|
||||
LIBRARY_DB_NAME = "library.json"
|
||||
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
||||
|
||||
@@ -1136,6 +1138,32 @@ def call_openai_compatible_chat(
|
||||
return content
|
||||
|
||||
|
||||
def list_openai_compatible_models(config: dict[str, Any]) -> list[str]:
|
||||
api_key = str(config.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise ValueError("尚未配置 GPT API Key")
|
||||
base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = redact_secret(exc.read().decode("utf-8", errors="replace"), api_key)[:1200]
|
||||
raise RuntimeError(f"模型列表请求失败:HTTP {exc.code} {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"模型列表请求失败:{redact_secret(exc.reason, api_key)}") from exc
|
||||
rows = result.get("data") if isinstance(result, dict) else []
|
||||
return sorted(
|
||||
str(row.get("id"))
|
||||
for row in (rows or [])
|
||||
if isinstance(row, dict) and str(row.get("id") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
||||
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
||||
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
||||
@@ -1161,6 +1189,10 @@ def library_db_path(review_root: Path) -> Path:
|
||||
return review_root / LIBRARY_DB_NAME
|
||||
|
||||
|
||||
def library_overrides_path(review_root: Path) -> Path:
|
||||
return review_root / "library_overrides.json"
|
||||
|
||||
|
||||
def series_config_path(review_root: Path, series_id: str) -> Path:
|
||||
return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
|
||||
|
||||
@@ -1303,7 +1335,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
||||
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
||||
session_id = session_public_id(session_root)
|
||||
cover_asset_path = str(metadata.get("cover_asset_path") or "")
|
||||
return {
|
||||
summary = {
|
||||
"id": session_id,
|
||||
"version": version,
|
||||
"source_name": source_name,
|
||||
@@ -1330,6 +1362,13 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
||||
"latest_export": latest_export,
|
||||
"reading_position": normalize_reading_position(state.get("reading_position", {})),
|
||||
}
|
||||
review_root = session_root.parent
|
||||
overrides = read_json(library_overrides_path(review_root), {})
|
||||
assignment = overrides.get("assignments", {}).get(session_id, {}) if isinstance(overrides, dict) else {}
|
||||
if isinstance(assignment, dict) and assignment.get("series_id"):
|
||||
summary["series_id"] = str(assignment["series_id"])
|
||||
summary["series"] = str(assignment.get("series") or summary["series"])
|
||||
return summary
|
||||
|
||||
|
||||
def rowTouched_py(row: dict[str, Any]) -> bool:
|
||||
@@ -1365,6 +1404,25 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
|
||||
|
||||
def build_library_index(review_root: Path) -> dict[str, Any]:
|
||||
sessions = list_review_sessions(review_root)
|
||||
jobs_by_session: dict[str, list[dict[str, Any]]] = {}
|
||||
for job in list_translation_jobs(review_root):
|
||||
source_session_id = str(job.get("source_session_id") or "")
|
||||
if source_session_id:
|
||||
jobs_by_session.setdefault(source_session_id, []).append(job)
|
||||
for session in sessions:
|
||||
session_jobs = jobs_by_session.get(str(session.get("id") or ""), [])
|
||||
latest = session_jobs[0] if session_jobs else None
|
||||
latest_status = str(latest.get("status") or "") if latest else ""
|
||||
session["translation_status"] = (
|
||||
"translated"
|
||||
if latest_status == "completed"
|
||||
else "translating"
|
||||
if latest_status in {"pending", "running", "paused"}
|
||||
else "failed"
|
||||
if latest_status in {"failed", "cancelled"}
|
||||
else "untranslated"
|
||||
)
|
||||
session["translation_job"] = latest
|
||||
series_map: dict[str, dict[str, Any]] = {}
|
||||
for session in sessions:
|
||||
series_id = str(session.get("series_id") or "uncategorized")
|
||||
@@ -1389,6 +1447,22 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
||||
series["publishers"].add(session["publisher"])
|
||||
if session.get("language"):
|
||||
series["languages"].add(session["language"])
|
||||
overrides = read_json(library_overrides_path(review_root), {})
|
||||
manual_series = overrides.get("series", {}) if isinstance(overrides, dict) else {}
|
||||
if isinstance(manual_series, dict):
|
||||
for series_id, title in manual_series.items():
|
||||
series_map.setdefault(
|
||||
str(series_id),
|
||||
{
|
||||
"id": str(series_id),
|
||||
"title": str(title),
|
||||
"count": 0,
|
||||
"books": [],
|
||||
"authors": set(),
|
||||
"publishers": set(),
|
||||
"languages": set(),
|
||||
},
|
||||
)
|
||||
series_list = []
|
||||
for item in series_map.values():
|
||||
series_list.append(
|
||||
@@ -1412,6 +1486,44 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
||||
return library
|
||||
|
||||
|
||||
def save_library_series_overrides(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
current = read_json(library_overrides_path(review_root), {"series": {}, "assignments": {}})
|
||||
current.setdefault("series", {})
|
||||
current.setdefault("assignments", {})
|
||||
action = str(data.get("action") or "").strip()
|
||||
if action in {"create", "rename"}:
|
||||
title = str(data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise ValueError("系列名称不能为空")
|
||||
series_id = safe_slug(str(data.get("series_id") or title), max_len=120)
|
||||
current["series"][series_id] = title
|
||||
elif action == "assign":
|
||||
session_id = str(data.get("session_id") or "").strip()
|
||||
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||
if not session_id or not series_id:
|
||||
raise ValueError("缺少 session_id 或 series_id")
|
||||
session_root_from_id(review_root, session_id)
|
||||
title = str(current["series"].get(series_id) or data.get("title") or series_id)
|
||||
current["series"][series_id] = title
|
||||
current["assignments"][session_id] = {"series_id": series_id, "series": title}
|
||||
elif action == "delete":
|
||||
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||
if not series_id:
|
||||
raise ValueError("缺少 series_id")
|
||||
if any(
|
||||
assignment.get("series_id") == series_id
|
||||
for assignment in current["assignments"].values()
|
||||
if isinstance(assignment, dict)
|
||||
):
|
||||
raise ValueError("系列仍有书籍,不能删除")
|
||||
current["series"].pop(series_id, None)
|
||||
else:
|
||||
raise ValueError("不支持的系列操作")
|
||||
current["updated_at"] = now_iso()
|
||||
write_json_atomic(library_overrides_path(review_root), current)
|
||||
return build_library_index(review_root)
|
||||
|
||||
|
||||
def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
|
||||
path = series_config_path(review_root, series_id)
|
||||
data = read_json(path, {})
|
||||
@@ -2768,6 +2880,14 @@ def add_translation_job_log(job: dict[str, Any], message: str, level: str = "inf
|
||||
|
||||
def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
||||
settings = job.get("settings") if isinstance(job.get("settings"), dict) else {}
|
||||
output_epub = Path(str(job.get("output_epub") or ""))
|
||||
output_download_url = ""
|
||||
if output_epub.name and job.get("status") == "completed":
|
||||
output_download_url = (
|
||||
f"/downloads/translation/{urllib.parse.quote(str(job.get('id') or ''))}/"
|
||||
f"{urllib.parse.quote(output_epub.name)}"
|
||||
f"?session_id={urllib.parse.quote(str(job.get('source_session_id') or ''))}"
|
||||
)
|
||||
return {
|
||||
"id": job.get("id", ""),
|
||||
"status": job.get("status", ""),
|
||||
@@ -2781,6 +2901,7 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"error": job.get("error", ""),
|
||||
"logs": job.get("logs", []),
|
||||
"output_epub": job.get("output_epub", ""),
|
||||
"output_download_url": output_download_url,
|
||||
"output_session_id": job.get("output_session_id", ""),
|
||||
"settings_summary": {
|
||||
"output_mode": settings.get("output_mode", "bilingual"),
|
||||
@@ -2788,10 +2909,29 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
|
||||
"glossary_path": settings.get("glossary_path", ""),
|
||||
"temperature": settings.get("temperature", 0.2),
|
||||
"chunk_target": settings.get("chunk_target", DEFAULT_TRANSLATION_CHUNK_TARGET),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_translation_jobs(review_root: Path) -> list[dict[str, Any]]:
|
||||
jobs: list[dict[str, Any]] = []
|
||||
root = translation_jobs_root(review_root)
|
||||
if not root.exists():
|
||||
return jobs
|
||||
for child in root.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
try:
|
||||
job = read_translation_job(review_root, child.name)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
if job:
|
||||
jobs.append(public_translation_job(job))
|
||||
jobs.sort(key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), reverse=True)
|
||||
return jobs
|
||||
|
||||
|
||||
def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||
output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
|
||||
if output_mode not in {"bilingual", "translated"}:
|
||||
@@ -2809,6 +2949,11 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("temperature 必须是数字") from exc
|
||||
temperature = max(0.0, min(1.0, temperature))
|
||||
try:
|
||||
chunk_target = int(data.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("每个 chunk 的目标正文块数必须是数字") from exc
|
||||
chunk_target = max(1, min(chunk_target, TRANSLATION_CHUNK_TARGET_MAX))
|
||||
def prompt_value(name: str) -> Any:
|
||||
if data.get(f"use_series_{name}"):
|
||||
return None
|
||||
@@ -2820,6 +2965,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
||||
"range_mode": range_mode,
|
||||
"limit": limit,
|
||||
"temperature": temperature,
|
||||
"chunk_target": chunk_target,
|
||||
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "",
|
||||
"translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")),
|
||||
"format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
|
||||
@@ -2827,6 +2973,44 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def build_translation_plan(
|
||||
items: list[dict[str, Any]], chunk_target: int = DEFAULT_TRANSLATION_CHUNK_TARGET
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Group source blocks without crossing XHTML files, preserving stable source order."""
|
||||
chunk_target = max(1, min(int(chunk_target), TRANSLATION_CHUNK_TARGET_MAX))
|
||||
chunks: list[dict[str, Any]] = []
|
||||
current: list[dict[str, Any]] = []
|
||||
current_file = ""
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current
|
||||
if not current:
|
||||
return
|
||||
text_chars = sum(len(str(item.get("source_text") or "")) for item in current)
|
||||
chunks.append(
|
||||
{
|
||||
"id": f"C{len(chunks) + 1:04d}",
|
||||
"file": current[0].get("file", ""),
|
||||
"title": current[0].get("document_title", ""),
|
||||
"first_item_id": current[0].get("id", ""),
|
||||
"last_item_id": current[-1].get("id", ""),
|
||||
"item_count": len(current),
|
||||
"source_characters": text_chars,
|
||||
"estimated_input_tokens": max(1, round(text_chars / 1.7)),
|
||||
}
|
||||
)
|
||||
current = []
|
||||
|
||||
for item in items:
|
||||
item_file = str(item.get("file") or "")
|
||||
if current and (item_file != current_file or len(current) >= chunk_target):
|
||||
flush()
|
||||
current_file = item_file
|
||||
current.append(item)
|
||||
flush()
|
||||
return chunks
|
||||
|
||||
|
||||
def html_looks_like_navigation(entry_name: str, text: str) -> bool:
|
||||
basename = posixpath.basename(entry_name).lower()
|
||||
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
|
||||
@@ -3143,6 +3327,22 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
if not selected_items:
|
||||
raise ValueError("没有找到可翻译的日文正文块")
|
||||
|
||||
checkpoint_path = translation_job_root(review_root, job_id) / "translations.json"
|
||||
checkpoint = read_json(checkpoint_path, {})
|
||||
if isinstance(checkpoint, dict):
|
||||
saved_translations = checkpoint.get("translations")
|
||||
if isinstance(saved_translations, dict):
|
||||
translations.update(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in saved_translations.items()
|
||||
if key and isinstance(value, str)
|
||||
}
|
||||
)
|
||||
completed_ids = {item["id"] for item in selected_items if item["id"] in translations}
|
||||
if completed_ids:
|
||||
add_translation_job_log(job, f"已从断点恢复 {len(completed_ids)} 个正文块。")
|
||||
|
||||
job["progress"].update(
|
||||
{
|
||||
"total": len(selected_items),
|
||||
@@ -3156,6 +3356,41 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
failed_count = 0
|
||||
errors: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(selected_items):
|
||||
job = read_translation_job(review_root, job_id) or job
|
||||
control = str(job.get("control") or "")
|
||||
if control == "cancel":
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = now_iso()
|
||||
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||
add_translation_job_log(job, job["progress"]["message"])
|
||||
write_translation_job(review_root, job)
|
||||
return
|
||||
if control == "pause":
|
||||
job["status"] = "paused"
|
||||
job["progress"]["message"] = "任务已暂停,断点已保留。"
|
||||
add_translation_job_log(job, job["progress"]["message"])
|
||||
write_translation_job(review_root, job)
|
||||
return
|
||||
if control == "cancel":
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = now_iso()
|
||||
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||
write_translation_job(review_root, job)
|
||||
return
|
||||
if job.get("status") == "paused":
|
||||
job["status"] = "running"
|
||||
job["control"] = ""
|
||||
add_translation_job_log(job, "任务已恢复。")
|
||||
if item["id"] in translations:
|
||||
job["progress"].update(
|
||||
{
|
||||
"current": index + 1,
|
||||
"percent": round((index + 1) / max(1, len(selected_items)) * 100, 1),
|
||||
"message": f"已从断点跳过 {item['id']}({index + 1}/{len(selected_items)})",
|
||||
}
|
||||
)
|
||||
write_translation_job(review_root, job)
|
||||
continue
|
||||
job["progress"].update(
|
||||
{
|
||||
"current": index,
|
||||
@@ -3184,13 +3419,11 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
failed_count += 1
|
||||
error_text = str(exc)[:900]
|
||||
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text})
|
||||
translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】"
|
||||
add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning")
|
||||
if index == 0 or (index + 1) % 5 == 0 or failed_count:
|
||||
write_json_atomic(
|
||||
translation_job_root(review_root, job_id) / "translations.json",
|
||||
{"items": selected_items, "translations": translations, "errors": errors},
|
||||
)
|
||||
write_json_atomic(
|
||||
checkpoint_path,
|
||||
{"items": selected_items, "translations": translations, "errors": errors},
|
||||
)
|
||||
|
||||
job["progress"].update(
|
||||
{
|
||||
@@ -3276,16 +3509,22 @@ def create_app(
|
||||
"/api/structure",
|
||||
"/api/reading-position",
|
||||
"/api/gpt/config",
|
||||
"/api/gpt/models",
|
||||
"/api/glossary",
|
||||
"/api/glossary/entry",
|
||||
"/api/glossary/search",
|
||||
"/api/apply",
|
||||
"/api/export",
|
||||
"/api/feedback",
|
||||
"/api/translation/defaults",
|
||||
"/api/translation/start",
|
||||
"/api/translation/jobs",
|
||||
}
|
||||
return (
|
||||
path in exact_paths
|
||||
or path.startswith("/api/row/")
|
||||
or (path.startswith("/api/session/") and "/translation-" in path)
|
||||
or path.startswith("/api/translation/jobs/")
|
||||
or path.startswith("/api/asset/")
|
||||
or path.startswith("/downloads/")
|
||||
)
|
||||
@@ -3442,6 +3681,14 @@ def create_app(
|
||||
def api_library():
|
||||
return jsonify(build_library_index(review_root))
|
||||
|
||||
@app.route("/api/library/series", methods=["POST"])
|
||||
def api_library_series():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return jsonify(save_library_series_overrides(review_root, data))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
|
||||
def api_series_config(series_id: str):
|
||||
series_id = safe_slug(series_id, max_len=120)
|
||||
@@ -3656,6 +3903,25 @@ def create_app(
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/gpt/models")
|
||||
def api_gpt_models():
|
||||
session_root, _epub_path = session_from_request()
|
||||
try:
|
||||
config = read_scoped_gpt_config(review_root, session_root)
|
||||
models = list_openai_compatible_models(config)
|
||||
configured_model = str(config.get("model") or DEFAULT_GPT_MODEL)
|
||||
return jsonify(
|
||||
{
|
||||
"models": models,
|
||||
"configured_model": configured_model,
|
||||
"configured_model_available": configured_model in models,
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except RuntimeError as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
|
||||
@app.route("/api/glossary", methods=["GET", "POST"])
|
||||
def api_glossary():
|
||||
session_root, _epub_path = session_from_request()
|
||||
@@ -3728,7 +3994,8 @@ def create_app(
|
||||
|
||||
@app.route("/api/translation/defaults")
|
||||
def api_translation_defaults():
|
||||
config = public_gpt_config(review_root)
|
||||
session_root, _epub_path = session_from_request()
|
||||
config = public_scoped_gpt_config(review_root, session_root)
|
||||
return jsonify(
|
||||
{
|
||||
"version": version,
|
||||
@@ -3738,6 +4005,7 @@ def create_app(
|
||||
"range_mode": "limit",
|
||||
"limit": DEFAULT_TRANSLATION_LIMIT,
|
||||
"temperature": 0.2,
|
||||
"chunk_target": DEFAULT_TRANSLATION_CHUNK_TARGET,
|
||||
"translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT,
|
||||
"format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT,
|
||||
"character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT,
|
||||
@@ -3792,12 +4060,42 @@ def create_app(
|
||||
except Exception as exc:
|
||||
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"])
|
||||
def api_translation_start():
|
||||
data = request.get_json(silent=True) or {}
|
||||
session_id = str(data.get("session_id") or "").strip()
|
||||
session_id = str(request.args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return jsonify({"error": "缺少 session_id"}), 400
|
||||
body_session_id = str(data.get("session_id") or "").strip()
|
||||
if body_session_id and body_session_id != session_id:
|
||||
return jsonify({"error": "请求中的 session_id 不一致"}), 400
|
||||
try:
|
||||
source_root = session_root_from_id(review_root, session_id)
|
||||
except ValueError as exc:
|
||||
@@ -3809,7 +4107,7 @@ def create_app(
|
||||
try:
|
||||
requested_base_url = str(data.get("base_url") or "").strip()
|
||||
requested_model = str(data.get("model") or "").strip()
|
||||
config = read_gpt_config(review_root)
|
||||
config = read_scoped_gpt_config(review_root, source_root)
|
||||
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
|
||||
return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400
|
||||
if requested_model and requested_model != config["model"]:
|
||||
@@ -3860,6 +4158,14 @@ def create_app(
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500
|
||||
|
||||
@app.route("/api/translation/jobs")
|
||||
def api_translation_jobs():
|
||||
jobs = list_translation_jobs(review_root)
|
||||
session_id = str(request.args.get("session_id") or "").strip()
|
||||
if session_id:
|
||||
jobs = [job for job in jobs if job.get("source_session_id") == session_id]
|
||||
return jsonify({"jobs": jobs, "count": len(jobs)})
|
||||
|
||||
@app.route("/api/translation/jobs/<job_id>")
|
||||
def api_translation_job(job_id: str):
|
||||
try:
|
||||
@@ -3868,6 +4174,50 @@ def create_app(
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not job:
|
||||
return jsonify({"error": "翻译任务不存在"}), 404
|
||||
if job.get("source_session_id") != request.args.get("session_id"):
|
||||
return jsonify({"error": "翻译任务不属于当前书籍会话"}), 404
|
||||
return jsonify({"job": public_translation_job(job)})
|
||||
|
||||
@app.route("/api/translation/jobs/<job_id>/<action>", methods=["POST"])
|
||||
def api_translation_job_action(job_id: str, action: str):
|
||||
if action not in {"pause", "resume", "cancel"}:
|
||||
return jsonify({"error": "不支持的任务操作"}), 404
|
||||
try:
|
||||
job = read_translation_job(review_root, job_id)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not job:
|
||||
return jsonify({"error": "翻译任务不存在"}), 404
|
||||
if job.get("source_session_id") != request.args.get("session_id"):
|
||||
return jsonify({"error": "翻译任务不属于当前书籍会话"}), 404
|
||||
status = str(job.get("status") or "")
|
||||
if action == "pause":
|
||||
if status not in {"pending", "running"}:
|
||||
return jsonify({"error": "当前任务不能暂停"}), 409
|
||||
job["control"] = "pause"
|
||||
add_translation_job_log(job, "已请求暂停,当前 API 调用完成后生效。")
|
||||
elif action == "cancel":
|
||||
if status in {"completed", "failed", "cancelled"}:
|
||||
return jsonify({"error": "当前任务不能取消"}), 409
|
||||
job["control"] = "cancel"
|
||||
add_translation_job_log(job, "已请求取消,当前 API 调用完成后生效。")
|
||||
else:
|
||||
if status not in {"paused", "failed", "cancelled"}:
|
||||
return jsonify({"error": "当前任务不能恢复"}), 409
|
||||
running_thread = app.config["TRANSLATION_THREADS"].get(job_id)
|
||||
if running_thread and running_thread.is_alive():
|
||||
return jsonify({"error": "翻译任务仍在停止中,请稍后重试"}), 409
|
||||
job["control"] = ""
|
||||
job["status"] = "pending"
|
||||
job["finished_at"] = ""
|
||||
job["error"] = ""
|
||||
add_translation_job_log(job, "已请求从断点恢复。")
|
||||
write_translation_job(review_root, job)
|
||||
thread = threading.Thread(target=run_translation_job, args=(review_root, job_id), daemon=True)
|
||||
app.config["TRANSLATION_THREADS"][job_id] = thread
|
||||
thread.start()
|
||||
return jsonify({"job": public_translation_job(job)})
|
||||
write_translation_job(review_root, job)
|
||||
return jsonify({"job": public_translation_job(job)})
|
||||
|
||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||
@@ -4047,6 +4397,24 @@ def create_app(
|
||||
return no_active_response()
|
||||
return send_from_directory(session_root / "exports", filename, as_attachment=True)
|
||||
|
||||
@app.route("/downloads/translation/<job_id>/<path:filename>")
|
||||
def download_translation(job_id: str, filename: str):
|
||||
try:
|
||||
job = read_translation_job(review_root, job_id)
|
||||
except ValueError:
|
||||
return jsonify({"error": "翻译任务不存在"}), 404
|
||||
if not job or job.get("source_session_id") != request.args.get("session_id"):
|
||||
return jsonify({"error": "翻译任务不存在"}), 404
|
||||
output_path = Path(str(job.get("output_epub") or ""))
|
||||
output_root = (review_root / TRANSLATION_OUTPUT_DIR_NAME).resolve()
|
||||
try:
|
||||
output_path.resolve().relative_to(output_root)
|
||||
except ValueError:
|
||||
return jsonify({"error": "翻译输出路径无效"}), 400
|
||||
if filename != output_path.name or not output_path.is_file():
|
||||
return jsonify({"error": "翻译输出不存在"}), 404
|
||||
return send_from_directory(output_path.parent, output_path.name, as_attachment=True)
|
||||
|
||||
@app.route("/downloads/feedback/<path:filename>")
|
||||
def download_feedback(filename: str):
|
||||
session_root, _epub_path = session_from_request()
|
||||
@@ -4127,7 +4495,11 @@ def run_daemon(args: argparse.Namespace) -> int:
|
||||
creationflags = 0
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if os.name == "nt":
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
creationflags = (
|
||||
subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
with log_path.open("a", encoding="utf-8") as log:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("server.py")
|
||||
SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH)
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
class FullBookTranslationTests(unittest.TestCase):
|
||||
def test_default_model_keeps_existing_compatibility(self):
|
||||
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-4o-mini")
|
||||
|
||||
def test_chunk_plan_keeps_file_boundaries(self):
|
||||
items = [
|
||||
{"id": "T00001", "file": "a.xhtml", "document_title": "A", "source_text": "一" * 17},
|
||||
{"id": "T00002", "file": "a.xhtml", "document_title": "A", "source_text": "二" * 17},
|
||||
{"id": "T00003", "file": "b.xhtml", "document_title": "B", "source_text": "三" * 17},
|
||||
]
|
||||
chunks = server.build_translation_plan(items, chunk_target=2)
|
||||
self.assertEqual([chunk["item_count"] for chunk in chunks], [2, 1])
|
||||
self.assertEqual([chunk["file"] for chunk in chunks], ["a.xhtml", "b.xhtml"])
|
||||
self.assertEqual(chunks[0]["first_item_id"], "T00001")
|
||||
self.assertEqual(chunks[0]["last_item_id"], "T00002")
|
||||
|
||||
def test_glossary_roundtrip_keeps_legacy_object_shape(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
glossary_file = root / "mingcibiao.json"
|
||||
server.write_json(root / "gpt_config.json", {"glossary_path": str(glossary_file)})
|
||||
payload = server.save_glossary(
|
||||
root,
|
||||
[
|
||||
{"source": "勇者", "target": "勇者"},
|
||||
{"source": "聖剣", "target": "圣剑(Excalibur)#保留有意义注音"},
|
||||
],
|
||||
)
|
||||
saved = server.read_json(glossary_file, {})
|
||||
self.assertEqual(saved["聖剣"], "圣剑(Excalibur)#保留有意义注音")
|
||||
self.assertEqual(payload["count"], 2)
|
||||
self.assertTrue(all(isinstance(value, str) for value in saved.values()))
|
||||
|
||||
def test_job_list_never_exposes_api_key(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
job = {
|
||||
"id": "tr_test",
|
||||
"status": "running",
|
||||
"source_session_id": "book",
|
||||
"settings": {"api_key": "secret-value", "model": "gpt-5.6-sol"},
|
||||
"logs": [],
|
||||
}
|
||||
server.write_translation_job(root, job)
|
||||
public = server.list_translation_jobs(root)
|
||||
self.assertNotIn("secret-value", str(public))
|
||||
|
||||
def test_library_translation_status_is_derived_from_jobs(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
session = root / "book_session"
|
||||
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||
server.write_json(
|
||||
server.session_manifest_path(session),
|
||||
{"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}},
|
||||
)
|
||||
server.write_translation_job(
|
||||
root,
|
||||
{
|
||||
"id": "tr_status",
|
||||
"status": "running",
|
||||
"source_session_id": session.name,
|
||||
"settings": {},
|
||||
"logs": [],
|
||||
},
|
||||
)
|
||||
library = server.build_library_index(root)
|
||||
self.assertEqual(library["sessions"][0]["translation_status"], "translating")
|
||||
|
||||
def test_failed_latest_job_is_not_reported_as_translating(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
session = root / "book_session"
|
||||
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||
server.write_json(
|
||||
server.session_manifest_path(session),
|
||||
{"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}},
|
||||
)
|
||||
server.write_translation_job(
|
||||
root,
|
||||
{
|
||||
"id": "tr_failed",
|
||||
"status": "failed",
|
||||
"source_session_id": session.name,
|
||||
"settings": {},
|
||||
"logs": [],
|
||||
},
|
||||
)
|
||||
library = server.build_library_index(root)
|
||||
self.assertEqual(library["sessions"][0]["translation_status"], "failed")
|
||||
|
||||
def test_manual_series_assignment_survives_library_rebuild(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
session = root / "book_session"
|
||||
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||
server.write_json(
|
||||
server.session_manifest_path(session),
|
||||
{
|
||||
"id": session.name,
|
||||
"source_name": "book.epub",
|
||||
"source_epub": "book.epub",
|
||||
"metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"},
|
||||
},
|
||||
)
|
||||
server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"})
|
||||
library = server.save_library_series_overrides(
|
||||
root,
|
||||
{"action": "assign", "session_id": session.name, "series_id": "manual"},
|
||||
)
|
||||
book = next(item for item in library["sessions"] if item["id"] == session.name)
|
||||
self.assertEqual(book["series_id"], "manual")
|
||||
self.assertEqual(book["series"], "手动系列")
|
||||
rebuilt = server.build_library_index(root)
|
||||
rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name)
|
||||
self.assertEqual(rebuilt_book["series_id"], "manual")
|
||||
|
||||
def test_models_endpoint_returns_only_public_ids(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
token = "test-sidecar-token"
|
||||
app = server.create_app(Path(temp_dir), access_token=token)
|
||||
with patch.object(server, "read_scoped_gpt_config", return_value={
|
||||
"api_key": "secret-value",
|
||||
"base_url": "https://example.test/v1",
|
||||
"model": "gpt-5.6-sol",
|
||||
}), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]):
|
||||
response = app.test_client().get(
|
||||
"/api/gpt/models?session_id=test-session",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.get_json()
|
||||
self.assertEqual(body["models"], ["gpt-5.6-sol"])
|
||||
self.assertNotIn("secret-value", str(body))
|
||||
|
||||
def test_translation_routes_require_token_and_explicit_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
token = "test-sidecar-token"
|
||||
app = server.create_app(Path(temp_dir), access_token=token)
|
||||
client = app.test_client()
|
||||
|
||||
missing_token = client.get("/api/translation/jobs?session_id=book")
|
||||
missing_session = client.get(
|
||||
"/api/translation/jobs",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(missing_token.status_code, 401)
|
||||
self.assertEqual(missing_session.status_code, 400)
|
||||
|
||||
def test_translation_job_control_rejects_another_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
token = "test-sidecar-token"
|
||||
server.write_translation_job(
|
||||
root,
|
||||
{
|
||||
"id": "tr_owned",
|
||||
"status": "paused",
|
||||
"source_session_id": "book-a",
|
||||
"settings": {},
|
||||
"logs": [],
|
||||
},
|
||||
)
|
||||
app = server.create_app(root, access_token=token)
|
||||
response = app.test_client().post(
|
||||
"/api/translation/jobs/tr_owned/resume?session_id=book-b",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
param(
|
||||
[string]$Remote = "akai-tools",
|
||||
[string]$Branch = "codex/readest-inline-review-mode",
|
||||
[string]$Branch = "codex/readest-web-translation-base",
|
||||
[switch]$Web,
|
||||
[switch]$Desktop,
|
||||
[switch]$SkipPull,
|
||||
[switch]$CheckOnly,
|
||||
[switch]$KeepRunning
|
||||
)
|
||||
|
||||
if ($Web -and $Desktop) {
|
||||
throw "-Web and -Desktop cannot be used together. Web is the default mode."
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
@@ -42,6 +48,17 @@ function Add-PathIfExists {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-SubmoduleCheckout {
|
||||
param([string]$RelativePath)
|
||||
|
||||
$fullPath = Join-Path $RepoRoot $RelativePath
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $fullPath ".git"))) {
|
||||
return $false
|
||||
}
|
||||
& git -C $fullPath rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
function Stop-OldReadestDev {
|
||||
if ($KeepRunning) {
|
||||
return
|
||||
@@ -124,7 +141,11 @@ try {
|
||||
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
|
||||
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
|
||||
|
||||
foreach ($command in @("git", "pnpm", "cargo")) {
|
||||
$requiredCommands = @("git", "pnpm")
|
||||
if ($Desktop) {
|
||||
$requiredCommands += "cargo"
|
||||
}
|
||||
foreach ($command in $requiredCommands) {
|
||||
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||
throw "Required command not found: $command"
|
||||
}
|
||||
@@ -134,6 +155,7 @@ try {
|
||||
Write-Host "Repository: $RepoRoot"
|
||||
Write-Host "Remote: $Remote"
|
||||
Write-Host "Branch: $Branch"
|
||||
Write-Host "Mode: $(if ($Desktop) { 'Desktop (Tauri)' } else { 'Web (Fast Refresh)' })"
|
||||
|
||||
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||
|
||||
@@ -163,7 +185,26 @@ try {
|
||||
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||
|
||||
Write-Step "Updating submodules"
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
|
||||
$requiredSubmodules = @(
|
||||
"packages/foliate-js",
|
||||
"packages/simplecc-wasm",
|
||||
"packages/js-mdict"
|
||||
)
|
||||
if ($Desktop) {
|
||||
$requiredSubmodules += "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"
|
||||
}
|
||||
$missingSubmodules = @($requiredSubmodules | Where-Object { -not (Test-SubmoduleCheckout $_) })
|
||||
if ($missingSubmodules.Count -gt 0) {
|
||||
$submoduleArgs = @(
|
||||
"-C", $RepoRoot,
|
||||
"-c", "http.version=HTTP/1.1",
|
||||
"-c", "submodule.fetchJobs=1",
|
||||
"submodule", "update", "--init", "--depth", "1", "--"
|
||||
) + $missingSubmodules
|
||||
Invoke-Checked "git" $submoduleArgs
|
||||
} else {
|
||||
Write-Host "Required submodules are already initialized."
|
||||
}
|
||||
|
||||
$changedFiles = @()
|
||||
if ($beforeHead -ne $afterHead) {
|
||||
@@ -181,6 +222,22 @@ try {
|
||||
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
|
||||
}
|
||||
|
||||
$vendorSentinel = Join-Path $RepoRoot "apps\readest-app\public\vendor\pdfjs\pdf.min.mjs"
|
||||
if ($needsInstall -or -not (Test-Path -LiteralPath $vendorSentinel)) {
|
||||
Write-Step "Preparing browser runtime assets"
|
||||
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "setup-vendors")
|
||||
}
|
||||
|
||||
if ($Desktop) {
|
||||
$tursoRoot = Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"
|
||||
$tursoOutput = Join-Path $tursoRoot "dist\index.js"
|
||||
if (-not (Test-Path -LiteralPath $tursoOutput)) {
|
||||
Write-Step "Building Tauri plugin dependencies"
|
||||
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "install", "--ignore-workspace", "--frozen-lockfile")
|
||||
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "build")
|
||||
}
|
||||
}
|
||||
|
||||
if ($CheckOnly) {
|
||||
Write-Step "Launcher check complete"
|
||||
exit 0
|
||||
@@ -188,9 +245,15 @@ try {
|
||||
|
||||
Stop-OldReadestDev
|
||||
|
||||
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")
|
||||
if ($Desktop) {
|
||||
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")
|
||||
} else {
|
||||
Write-Step "Starting Readest web development server"
|
||||
Write-Host "Open http://localhost:3000. Saved React and CSS changes appear through Fast Refresh."
|
||||
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "dev-web")
|
||||
}
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "Readest launcher failed:" -ForegroundColor Red
|
||||
|
||||
@@ -15,6 +15,10 @@ function Assert-Match {
|
||||
}
|
||||
|
||||
Assert-Match '\[string\]\$Remote\s*=\s*"akai-tools"' "Launcher must pull from akai-tools by default."
|
||||
Assert-Match '\[switch\]\$Desktop' "Launcher must expose an explicit desktop mode."
|
||||
Assert-Match 'if\s*\(\$Desktop\)[\s\S]*tauri[\s\S]*else[\s\S]*dev-web' "Launcher must default to dev-web while preserving Tauri."
|
||||
Assert-Match '\$requiredCommands\s*=\s*@\("git",\s*"pnpm"\)' "Web mode must not require Cargo."
|
||||
Assert-Match 'submodule\.fetchJobs=1' "Launcher must initialize missing submodules conservatively."
|
||||
Assert-Match 'status\s+--porcelain\s+--untracked-files=no\s+--ignore-submodules=dirty' "Clean check must ignore runtime logs and dirty submodule contents."
|
||||
Assert-Match 'Get-NetTCPConnection[^\r\n]*-LocalPort\s+3000' "Launcher must inspect the Tauri dev port before starting."
|
||||
Assert-Match 'Stop-Process[^\r\n]*-Id\s+\$ownerPid' "Launcher must stop an old Readest/Next process that owns port 3000."
|
||||
|
||||
Reference in New Issue
Block a user