feat: add inline review mode to reader
This commit is contained in:
@@ -397,7 +397,7 @@ fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option
|
||||
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body = serde_json::json!({ "epub_path": raw_path }).to_string();
|
||||
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
|
||||
let payload = post_json(url, "/api/session/from-path", &body)?;
|
||||
Ok(payload
|
||||
.get("id")
|
||||
@@ -405,7 +405,11 @@ fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option
|
||||
.map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
fn run_command(program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput, String> {
|
||||
fn run_command(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<CommandOutput, String> {
|
||||
let (ok, output) = run_command_allow_failure(program, args, cwd)?;
|
||||
if ok {
|
||||
Ok(output)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { __testing } from '@/app/reader/components/ReviewModeController';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
describe('ReviewModeController marks', () => {
|
||||
test('marks bilingual source and target paragraphs by file and index', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = [
|
||||
'<p class="sourceText">原文一</p>',
|
||||
'<p>译文一</p>',
|
||||
'<p class="sourceText">原文二</p>',
|
||||
'<p>译文二</p>',
|
||||
].join('');
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'OEBPS/Text/chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00002',
|
||||
file: 'Text/chapter.xhtml',
|
||||
ja_p_index: 2,
|
||||
cn_p_index: 3,
|
||||
jp_html: '原文二',
|
||||
jp_text: '原文二',
|
||||
cn_html: '译文二',
|
||||
current_html: '<span>新译文二</span><script>alert(1)</script>',
|
||||
},
|
||||
],
|
||||
'R00002',
|
||||
);
|
||||
|
||||
const paragraphs = Array.from(doc.querySelectorAll('p'));
|
||||
expect(paragraphs[2]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
|
||||
expect(paragraphs[2]?.classList.contains('readest-review-source')).toBe(true);
|
||||
expect(paragraphs[3]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
|
||||
expect(paragraphs[3]?.classList.contains('readest-review-target')).toBe(true);
|
||||
expect(paragraphs[3]?.innerHTML).toBe('<span>新译文二</span>');
|
||||
});
|
||||
|
||||
test('uses section id when foliate sections do not expose href', () => {
|
||||
const bookDoc = {
|
||||
sections: [{ id: 'OEBPS/Text/chapter.xhtml' }],
|
||||
} as unknown as BookDoc;
|
||||
|
||||
expect(__testing.sectionHrefAt(bookDoc, 0)).toBe('OEBPS/Text/chapter.xhtml');
|
||||
});
|
||||
|
||||
test('clearReviewMarks restores original target HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p class="sourceText">原文</p><p><ruby>旧<rt>old</rt></ruby>译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '<ruby>旧<rt>old</rt></ruby>译文',
|
||||
current_html: '<span>新译文</span>',
|
||||
},
|
||||
],
|
||||
'',
|
||||
);
|
||||
|
||||
__testing.clearReviewMarks(doc);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1];
|
||||
expect(target?.innerHTML).toBe('<ruby>旧<rt>old</rt></ruby>译文');
|
||||
expect(target?.hasAttribute('data-readest-review-row-id')).toBe(false);
|
||||
});
|
||||
|
||||
test('strips event handlers and dangerous links from target HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文</p><p>译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '译文',
|
||||
current_html: '<span onclick="alert(1)"><a href="javascript:alert(1)">新译文</a></span>',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1];
|
||||
expect(target?.innerHTML).toBe('<span>新译文</span>');
|
||||
});
|
||||
|
||||
test('updates active classes without rewriting translated paragraph HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文一</p><p>译文一</p><p>原文二</p><p>译文二</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文一',
|
||||
jp_text: '原文一',
|
||||
cn_html: '译文一',
|
||||
current_html: '<span>新译文一</span>',
|
||||
},
|
||||
{
|
||||
id: 'R00002',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 2,
|
||||
cn_p_index: 3,
|
||||
jp_html: '原文二',
|
||||
jp_text: '原文二',
|
||||
cn_html: '译文二',
|
||||
current_html: '<span>新译文二</span>',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1]!;
|
||||
target.dataset['localState'] = 'kept';
|
||||
|
||||
__testing.applyActiveReviewMark(doc, 'R00002');
|
||||
|
||||
expect(target.innerHTML).toBe('<span>新译文一</span>');
|
||||
expect(target.dataset['localState']).toBe('kept');
|
||||
expect(target.classList.contains('readest-review-active')).toBe(false);
|
||||
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createReviewSessionFromPath,
|
||||
exportReviewedEpub,
|
||||
generateReviewFeedback,
|
||||
loadInlineReviewData,
|
||||
loadReviewGlossary,
|
||||
saveReviewGptConfig,
|
||||
retranslateReviewRow,
|
||||
saveReviewRow,
|
||||
saveReviewGlossary,
|
||||
sidecarApi,
|
||||
switchReviewGlossaryPath,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const stubJsonFetch = () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
status: 'ok',
|
||||
updated_at: '2026-07-09T00:00:00Z',
|
||||
current_html: '<span>净化译文</span>',
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
};
|
||||
|
||||
const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
|
||||
const input = fetchMock.mock.calls[0]?.[0];
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (typeof input === 'string') return input;
|
||||
return input?.toString() || '';
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const url = new URL(firstFetchUrl(fetchMock));
|
||||
expect(url.pathname).toBe('/api/rows');
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
});
|
||||
|
||||
test('row operations are scoped to the current review session', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await saveReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', {
|
||||
current_html: '<span>译文</span>',
|
||||
marked: true,
|
||||
issue_type: '误译',
|
||||
severity: '严重',
|
||||
tags: '术语',
|
||||
comment: '备注',
|
||||
learn_note: '长期规则',
|
||||
});
|
||||
await retranslateReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', '');
|
||||
await generateReviewFeedback('http://127.0.0.1:5177', 'session-a');
|
||||
await exportReviewedEpub('http://127.0.0.1:5177', 'session-a');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
for (const call of fetchMock.mock.calls as unknown as [
|
||||
string | URL,
|
||||
RequestInit | undefined,
|
||||
][]) {
|
||||
const input = call[0];
|
||||
const url = new URL(input instanceof URL ? input.toString() : input);
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
}
|
||||
});
|
||||
|
||||
test('inline launch creates sessions without activating sidecar global session', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await createReviewSessionFromPath('http://127.0.0.1:5177', 'C:/books/demo.epub');
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as [string | URL, RequestInit][];
|
||||
const body = JSON.parse(String(calls[0]?.[1]?.body || '{}'));
|
||||
expect(body).toMatchObject({
|
||||
epub_path: 'C:/books/demo.epub',
|
||||
activate: false,
|
||||
reset: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('inline bootstrap and config operations are scoped to the current review session', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
has_session: true,
|
||||
rows: [],
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await loadInlineReviewData('http://127.0.0.1:5177', 'session-a');
|
||||
await saveReviewGptConfig('http://127.0.0.1:5177', 'session-a', {
|
||||
base_url: 'https://api.example.test/v1',
|
||||
model: 'model-a',
|
||||
});
|
||||
await loadReviewGlossary('http://127.0.0.1:5177', 'session-a');
|
||||
await switchReviewGlossaryPath('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json');
|
||||
await saveReviewGlossary('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json', []);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(7);
|
||||
for (const call of fetchMock.mock.calls as unknown as [
|
||||
string | URL,
|
||||
RequestInit | undefined,
|
||||
][]) {
|
||||
const input = call[0];
|
||||
const url = new URL(input instanceof URL ? input.toString() : input);
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
|
||||
beforeEach(() => {
|
||||
useReviewModeStore.setState({
|
||||
activeBookKey: null,
|
||||
isPanelVisible: false,
|
||||
isPanelPinned: false,
|
||||
panelWidth: '32%',
|
||||
books: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewModeStore', () => {
|
||||
test('enables review mode for a book and opens the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBe('book-a');
|
||||
expect(state.isPanelVisible).toBe(true);
|
||||
expect(state.books['book-a']?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('selects rows without replacing loaded review data', () => {
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
baseUrl: 'http://127.0.0.1:5177',
|
||||
sessionId: 'session-a',
|
||||
rows: [
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '译文',
|
||||
current_html: '译文',
|
||||
},
|
||||
],
|
||||
});
|
||||
useReviewModeStore.getState().selectRow('book-a', 'R00001');
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBe('book-a');
|
||||
expect(state.isPanelVisible).toBe(true);
|
||||
expect(state.books['book-a']?.baseUrl).toBe('http://127.0.0.1:5177');
|
||||
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
|
||||
});
|
||||
|
||||
test('updates one review row after save', () => {
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
rows: [
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '旧译文',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useReviewModeStore.getState().updateRow('book-a', {
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '新译文',
|
||||
edited: true,
|
||||
});
|
||||
|
||||
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.current_html).toBe('新译文');
|
||||
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.edited).toBe(true);
|
||||
});
|
||||
|
||||
test('disabling the active book closes the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', false);
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBeNull();
|
||||
expect(state.isPanelVisible).toBe(false);
|
||||
expect(state.books['book-a']?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('clears an active book and closes the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
baseUrl: 'http://127.0.0.1:5177',
|
||||
sessionId: 'session-a',
|
||||
});
|
||||
|
||||
useReviewModeStore.getState().clearBook('book-a');
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.books['book-a']).toBeUndefined();
|
||||
expect(state.activeBookKey).toBeNull();
|
||||
expect(state.isPanelVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import FoliateViewer from './FoliateViewer';
|
||||
import SectionInfo from './SectionInfo';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import PageNavigationButtons from './PageNavigationButtons';
|
||||
import ReviewModeController from './ReviewModeController';
|
||||
import FooterBar from './footerbar/FooterBar';
|
||||
import ProgressBar from './ProgressBar';
|
||||
import Ribbon from './Ribbon';
|
||||
@@ -171,6 +172,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
|
||||
gridInsets={gridInsets}
|
||||
contentInsets={contentInsets}
|
||||
/>
|
||||
<ReviewModeController bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
{(showFooter || viewSettings.doubleBorder) && (
|
||||
|
||||
@@ -27,6 +27,7 @@ import QuickActionMenu from './annotator/QuickActionMenu';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import ReviewModeToggler from './ReviewModeToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import TranslationToggler from './TranslationToggler';
|
||||
import ViewMenu from './ViewMenu';
|
||||
@@ -282,6 +283,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
|
||||
<div className='header-tools-end bg-base-100 z-20 ms-auto flex h-full min-w-max items-center gap-x-4 ps-2 max-[350px]:gap-x-2'>
|
||||
{!isHeaderCompact && <SettingsToggler bookKey={bookKey} />}
|
||||
<ReviewModeToggler bookKey={bookKey} />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
label={_('View Options')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { useGamepad } from '@/hooks/useGamepad';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
@@ -37,6 +38,7 @@ import Spinner from '@/components/Spinner';
|
||||
import SideBar from './sidebar/SideBar';
|
||||
import Notebook from './notebook/Notebook';
|
||||
import BooksGrid from './BooksGrid';
|
||||
import ReviewPanel from './ReviewPanel';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
|
||||
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
|
||||
@@ -50,6 +52,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const { getConfig, getBookData, saveConfig } = useBookDataStore();
|
||||
const { getView, setBookKeys, getViewSettings } = useReaderStore();
|
||||
const { initViewState, getViewState, clearViewState } = useReaderStore();
|
||||
const clearReviewBook = useReviewModeStore((state) => state.clearBook);
|
||||
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
@@ -176,6 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => {
|
||||
console.log('Closing book', bookKey);
|
||||
clearReviewBook(bookKey);
|
||||
|
||||
const viewState = getViewState(bookKey);
|
||||
if (viewState?.isPrimary && appService?.isDesktopApp) {
|
||||
@@ -291,6 +295,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
onGoToLibrary={handleCloseBooksToLibrary}
|
||||
/>
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
|
||||
<ReviewPanel />
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
|
||||
const STYLE_ID = 'readest-inline-review-style';
|
||||
const ROW_ATTR = 'data-readest-review-row-id';
|
||||
const ROLE_ATTR = 'data-readest-review-role';
|
||||
const ORIGINAL_HTML_ATTR = 'data-readest-review-original-html';
|
||||
|
||||
const reviewHtmlPurifyOptions = {
|
||||
ALLOWED_TAGS: [
|
||||
'ruby',
|
||||
'rb',
|
||||
'rt',
|
||||
'rp',
|
||||
'span',
|
||||
'mark',
|
||||
'br',
|
||||
'em',
|
||||
'strong',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'sub',
|
||||
'sup',
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
const sanitizeReviewHtml = (html: string) =>
|
||||
DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions);
|
||||
|
||||
const emptyReviewRows: ReviewRow[] = [];
|
||||
|
||||
const normalizeFile = (value?: string) =>
|
||||
(() => {
|
||||
const raw = (value || '').split('#')[0] || '';
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
})()
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
|
||||
const sameFile = (rowFile: string, sectionHref?: string) => {
|
||||
const row = normalizeFile(rowFile);
|
||||
const href = normalizeFile(sectionHref);
|
||||
if (!row || !href) return false;
|
||||
return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`);
|
||||
};
|
||||
|
||||
const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => {
|
||||
const section = bookDoc.sections[index ?? -1];
|
||||
return section?.href || section?.id;
|
||||
};
|
||||
|
||||
const clearReviewMarks = (doc: Document) => {
|
||||
doc.getElementById(STYLE_ID)?.remove();
|
||||
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
|
||||
const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR);
|
||||
if (originalHtml !== null) {
|
||||
element.innerHTML = originalHtml;
|
||||
}
|
||||
element.removeAttribute(ROW_ATTR);
|
||||
element.removeAttribute(ROLE_ATTR);
|
||||
element.removeAttribute(ORIGINAL_HTML_ATTR);
|
||||
element.classList.remove(
|
||||
'readest-review-source',
|
||||
'readest-review-target',
|
||||
'readest-review-active',
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureReviewStyle = (doc: Document) => {
|
||||
if (doc.getElementById(STYLE_ID)) return;
|
||||
const style = doc.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
[${ROW_ATTR}] {
|
||||
cursor: pointer;
|
||||
user-select: text;
|
||||
outline-offset: 0.18em;
|
||||
transition: background-color 120ms ease, outline-color 120ms ease;
|
||||
}
|
||||
.readest-review-source {
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
outline: 1px dashed rgba(127, 127, 127, 0.64);
|
||||
}
|
||||
.readest-review-target {
|
||||
background: rgba(22, 163, 74, 0.14);
|
||||
outline: 1px solid rgba(22, 163, 74, 0.72);
|
||||
}
|
||||
.readest-review-active {
|
||||
background: rgba(37, 99, 235, 0.18) !important;
|
||||
outline: 2px solid currentColor !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
};
|
||||
|
||||
const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => {
|
||||
const element = target as Element | null;
|
||||
if (!element || typeof element.closest !== 'function') return null;
|
||||
if (element.closest('a, button, input, textarea, select, video, audio')) return null;
|
||||
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => {
|
||||
const element =
|
||||
node?.nodeType === Node.ELEMENT_NODE
|
||||
? (node as Element)
|
||||
: (node?.parentElement as Element | null);
|
||||
if (!element || typeof element.closest !== 'function') return null;
|
||||
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const selectedReviewTarget = (doc: Document): HTMLElement | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
return (
|
||||
closestReviewTargetFromNode(range.commonAncestorContainer) ||
|
||||
closestReviewTargetFromNode(range.startContainer) ||
|
||||
closestReviewTargetFromNode(range.endContainer)
|
||||
);
|
||||
};
|
||||
|
||||
const markParagraph = (
|
||||
element: Element | undefined,
|
||||
row: ReviewRow,
|
||||
role: 'source' | 'target',
|
||||
active: boolean,
|
||||
) => {
|
||||
const htmlElement = element as HTMLElement | undefined;
|
||||
if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return;
|
||||
htmlElement.setAttribute(ROW_ATTR, row.id);
|
||||
htmlElement.setAttribute(ROLE_ATTR, role);
|
||||
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
|
||||
htmlElement.classList.toggle('readest-review-active', active);
|
||||
if (role === 'target' && row.current_html) {
|
||||
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
|
||||
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
|
||||
}
|
||||
htmlElement.innerHTML = sanitizeReviewHtml(row.current_html);
|
||||
}
|
||||
};
|
||||
|
||||
const applyActiveReviewMark = (doc: Document, selectedRowId: string) => {
|
||||
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
|
||||
element.classList.toggle(
|
||||
'readest-review-active',
|
||||
element.getAttribute(ROW_ATTR) === selectedRowId,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const applyReviewMarks = (
|
||||
doc: Document,
|
||||
sectionHref: string | undefined,
|
||||
rows: ReviewRow[],
|
||||
selectedRowId: string,
|
||||
) => {
|
||||
clearReviewMarks(doc);
|
||||
const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref));
|
||||
if (!sectionRows.length) return;
|
||||
|
||||
ensureReviewStyle(doc);
|
||||
const paragraphs = Array.from(doc.querySelectorAll('p'));
|
||||
for (const row of sectionRows) {
|
||||
const active = row.id === selectedRowId;
|
||||
markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active);
|
||||
markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active);
|
||||
}
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
applyReviewMarks,
|
||||
applyActiveReviewMark,
|
||||
clearReviewMarks,
|
||||
sectionHrefAt,
|
||||
};
|
||||
|
||||
const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
bookKey,
|
||||
bookDoc,
|
||||
}) => {
|
||||
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
|
||||
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
|
||||
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
|
||||
const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || '');
|
||||
const selectRow = useReviewModeStore((state) => state.selectRow);
|
||||
const selectedRowIdRef = useRef(selectedRowId);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRowIdRef.current = selectedRowId;
|
||||
}, [selectedRowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
const cleanupLoadedDocs = () => {
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
clearReviewMarks(doc);
|
||||
}
|
||||
};
|
||||
|
||||
if (!enabled || !rows.length) {
|
||||
cleanupLoadedDocs();
|
||||
return;
|
||||
}
|
||||
|
||||
const applyToLoadedDocs = () => {
|
||||
for (const { doc, index } of view.renderer.getContents()) {
|
||||
applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = closestReviewTarget(event.target);
|
||||
if (!target) return;
|
||||
const rowId = target.getAttribute(ROW_ATTR);
|
||||
if (!rowId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if ('stopImmediatePropagation' in event) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
selectRow(bookKey, rowId);
|
||||
};
|
||||
|
||||
const handleSelectionEnd = (event: Event) => {
|
||||
const doc = event.currentTarget as Document | null;
|
||||
if (!doc?.getSelection) return;
|
||||
window.setTimeout(() => {
|
||||
const target = selectedReviewTarget(doc);
|
||||
const rowId = target?.getAttribute(ROW_ATTR);
|
||||
if (rowId) selectRow(bookKey, rowId);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const listenedDocs = new Set<Document>();
|
||||
|
||||
const addDocListeners = (doc: Document) => {
|
||||
if (listenedDocs.has(doc)) return;
|
||||
listenedDocs.add(doc);
|
||||
doc.addEventListener('click', handleClick, true);
|
||||
doc.addEventListener('mouseup', handleSelectionEnd, true);
|
||||
doc.addEventListener('touchend', handleSelectionEnd, true);
|
||||
doc.addEventListener('keyup', handleSelectionEnd, true);
|
||||
};
|
||||
|
||||
const removeDocListeners = (doc: Document) => {
|
||||
listenedDocs.delete(doc);
|
||||
doc.removeEventListener('click', handleClick, true);
|
||||
doc.removeEventListener('mouseup', handleSelectionEnd, true);
|
||||
doc.removeEventListener('touchend', handleSelectionEnd, true);
|
||||
doc.removeEventListener('keyup', handleSelectionEnd, true);
|
||||
};
|
||||
|
||||
const handleLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail;
|
||||
if (!detail?.doc) return;
|
||||
applyReviewMarks(
|
||||
detail.doc,
|
||||
sectionHrefAt(bookDoc, detail.index),
|
||||
rows,
|
||||
selectedRowIdRef.current,
|
||||
);
|
||||
addDocListeners(detail.doc);
|
||||
};
|
||||
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
addDocListeners(doc);
|
||||
}
|
||||
applyToLoadedDocs();
|
||||
view.addEventListener('load', handleLoad);
|
||||
|
||||
return () => {
|
||||
view.removeEventListener('load', handleLoad);
|
||||
for (const doc of listenedDocs) {
|
||||
removeDocListeners(doc);
|
||||
clearReviewMarks(doc);
|
||||
}
|
||||
};
|
||||
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || !enabled || !rows.length) return;
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
applyActiveReviewMark(doc, selectedRowId);
|
||||
}
|
||||
}, [bookKey, enabled, rows.length, selectedRowId, view]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ReviewModeController;
|
||||
@@ -0,0 +1,99 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
|
||||
|
||||
interface ReviewModeTogglerProps {
|
||||
bookKey: string;
|
||||
}
|
||||
|
||||
const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const bookData = useBookDataStore((state) => state.getBookData(bookKey));
|
||||
const reviewState = useReviewModeStore((state) => state.books[bookKey]);
|
||||
const {
|
||||
setActiveBookKey,
|
||||
setPanelVisible,
|
||||
setBookLoading,
|
||||
setBookError,
|
||||
setBookEnabled,
|
||||
setBookData,
|
||||
} = useReviewModeStore();
|
||||
|
||||
const enabled = !!reviewState?.enabled;
|
||||
const loading = !!reviewState?.loading;
|
||||
|
||||
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
|
||||
|
||||
const handleToggleReviewMode = async () => {
|
||||
if (appService?.isMobile) {
|
||||
setHoveredBookKey('');
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
setBookEnabled(bookKey, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bookData?.book) {
|
||||
setBookError(bookKey, _('Unable to open book'));
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveBookKey(bookKey);
|
||||
setPanelVisible(true);
|
||||
setBookLoading(bookKey, true);
|
||||
|
||||
try {
|
||||
const launch = await launchInlineReviewEditor(appService, bookData.book);
|
||||
const data = await loadInlineReviewData(launch.url, launch.sessionId);
|
||||
setBookData(bookKey, {
|
||||
baseUrl: launch.url,
|
||||
sessionId: launch.sessionId,
|
||||
reviewRoot: launch.reviewRoot,
|
||||
version: launch.version,
|
||||
session: data.session,
|
||||
rows: data.rows,
|
||||
gptConfig: data.gptConfig,
|
||||
selectedRowId: data.rows[0]?.id || '',
|
||||
});
|
||||
setBookEnabled(bookKey, true);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: data.rows.length
|
||||
? `审校模式已开启,共 ${data.rows.length} 段`
|
||||
: '审校模式已开启,但当前 EPUB 没有识别到双语段落',
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setBookError(bookKey, message);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message,
|
||||
timeout: 3500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<span className='text-sm font-semibold leading-none'>校</span>}
|
||||
onClick={handleToggleReviewMode}
|
||||
disabled={loading}
|
||||
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
|
||||
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewModeToggler;
|
||||
@@ -0,0 +1,958 @@
|
||||
import clsx from 'clsx';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Check, Download, FileText, Pin, PinOff, RefreshCw, Save, Sparkles, X } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { usePanelResize } from '@/hooks/usePanelResize';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { getPanelTopInset } from '@/utils/insets';
|
||||
import { openExternalUrl } from '@/utils/open';
|
||||
import {
|
||||
exportReviewedEpub,
|
||||
generateReviewFeedback,
|
||||
loadReviewGlossary,
|
||||
retranslateReviewRow,
|
||||
saveReviewGlossary,
|
||||
saveReviewGptConfig,
|
||||
saveReviewRow,
|
||||
sidecarApi,
|
||||
switchReviewGlossaryPath,
|
||||
type ReviewEditPayload,
|
||||
type ReviewGlossaryEntry,
|
||||
type ReviewGlossaryPayload,
|
||||
type ReviewGptConfig,
|
||||
type ReviewSessionPayload,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
const MIN_REVIEW_PANEL_WIDTH = 0.24;
|
||||
const MAX_REVIEW_PANEL_WIDTH = 0.48;
|
||||
|
||||
const cnHtmlPurifyOptions = {
|
||||
ALLOWED_TAGS: [
|
||||
'ruby',
|
||||
'rb',
|
||||
'rt',
|
||||
'rp',
|
||||
'span',
|
||||
'mark',
|
||||
'br',
|
||||
'em',
|
||||
'strong',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'sub',
|
||||
'sup',
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
type GptForm = {
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key: string;
|
||||
glossary_path: string;
|
||||
translation_prompt: string;
|
||||
format_prompt: string;
|
||||
character_prompt: string;
|
||||
};
|
||||
|
||||
const emptyEditState: ReviewEditPayload = {
|
||||
current_html: '',
|
||||
marked: false,
|
||||
issue_type: '',
|
||||
severity: '',
|
||||
tags: '',
|
||||
comment: '',
|
||||
learn_note: '',
|
||||
};
|
||||
|
||||
const emptyGptForm: GptForm = {
|
||||
base_url: '',
|
||||
model: '',
|
||||
api_key: '',
|
||||
glossary_path: '',
|
||||
translation_prompt: '',
|
||||
format_prompt: '',
|
||||
character_prompt: '',
|
||||
};
|
||||
|
||||
const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions);
|
||||
|
||||
const stripHtml = (html: string) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = sanitizeInlineHtml(html);
|
||||
return div.textContent || div.innerText || '';
|
||||
};
|
||||
|
||||
const rowTitle = (row: { document_title?: string; file_label?: string; file: string }) =>
|
||||
row.document_title || row.file_label || row.file;
|
||||
|
||||
const toGptForm = (config: ReviewGptConfig | null | undefined): GptForm => ({
|
||||
base_url: config?.base_url || '',
|
||||
model: config?.model || '',
|
||||
api_key: '',
|
||||
glossary_path: config?.glossary_path || '',
|
||||
translation_prompt:
|
||||
config?.translation_prompt || config?.prompt_defaults?.translation_prompt || '',
|
||||
format_prompt: config?.format_prompt || config?.prompt_defaults?.format_prompt || '',
|
||||
character_prompt: config?.character_prompt || config?.prompt_defaults?.character_prompt || '',
|
||||
});
|
||||
|
||||
const PanelButton = ({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = 'ghost',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: 'primary' | 'ghost';
|
||||
}) => (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
'btn h-9 min-h-9 rounded-md px-3 text-sm',
|
||||
variant === 'primary' ? 'btn-primary' : 'btn-ghost eink-bordered',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
function GptConfigPanel({
|
||||
baseUrl,
|
||||
sessionId,
|
||||
form,
|
||||
setForm,
|
||||
onSaved,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
sessionId: string | null | undefined;
|
||||
form: GptForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<GptForm>>;
|
||||
onSaved: (config: ReviewGptConfig) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const config = await saveReviewGptConfig(baseUrl, sessionId, {
|
||||
base_url: form.base_url || undefined,
|
||||
model: form.model || undefined,
|
||||
api_key: form.api_key,
|
||||
glossary_path: form.glossary_path,
|
||||
translation_prompt: form.translation_prompt,
|
||||
format_prompt: form.format_prompt,
|
||||
character_prompt: form.character_prompt,
|
||||
});
|
||||
setForm((current) => ({ ...current, api_key: '' }));
|
||||
onSaved(config);
|
||||
setMessage('已保存 API、提示词与术语表路径。');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-base-200 flex w-full items-center justify-between rounded-md px-3 py-2 text-start'
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className='text-sm font-semibold'>API 与提示词</span>
|
||||
<span className='text-base-content/60 text-xs'>{open ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='grid gap-3 px-3 pb-3'>
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
Base URL
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={form.base_url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, base_url: event.target.value }))
|
||||
}
|
||||
placeholder='https://api.openai.com/v1'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
模型
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={form.model}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, model: event.target.value }))
|
||||
}
|
||||
placeholder='gpt-4.1'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
API Key
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={form.api_key}
|
||||
type='password'
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, api_key: event.target.value }))
|
||||
}
|
||||
placeholder='留空表示沿用已保存密钥'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
术语表路径
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={form.glossary_path}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, glossary_path: event.target.value }))
|
||||
}
|
||||
placeholder='mingcibiao.json'
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
翻译内容提示词
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-20 rounded-md text-sm'
|
||||
value={form.translation_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, translation_prompt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
格式与标点提示词
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
|
||||
value={form.format_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, format_prompt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className='grid gap-1 text-xs font-medium'>
|
||||
角色状态与口吻提示词
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
|
||||
value={form.character_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, character_prompt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<PanelButton
|
||||
onClick={save}
|
||||
disabled={saving || !baseUrl}
|
||||
icon={<Save className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
{saving ? '保存中' : '保存设置'}
|
||||
</PanelButton>
|
||||
{message ? <span className='text-base-content/70 text-xs'>{message}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlossaryPanel({
|
||||
baseUrl,
|
||||
sessionId,
|
||||
path,
|
||||
setPath,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
sessionId: string | null | undefined;
|
||||
path: string;
|
||||
setPath: (path: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [entries, setEntries] = useState<ReviewGlossaryEntry[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [message, setMessage] = useState('未读取术语表');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return entries.map((entry, index) => ({ entry, index }));
|
||||
return entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(
|
||||
({ entry }) =>
|
||||
entry.source.toLowerCase().includes(needle) ||
|
||||
entry.target.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [entries, query]);
|
||||
|
||||
const applyPayload = (payload: ReviewGlossaryPayload) => {
|
||||
setEntries(payload.entries || []);
|
||||
setLoaded(true);
|
||||
setDirty(false);
|
||||
if (payload.path) setPath(payload.path);
|
||||
setMessage(
|
||||
`当前术语表:${payload.path || path || '未设置'} · ${payload.count ?? payload.entries?.length ?? 0} 条`,
|
||||
);
|
||||
};
|
||||
|
||||
const load = async (forcePath = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload =
|
||||
forcePath && path
|
||||
? await switchReviewGlossaryPath(baseUrl, sessionId, path)
|
||||
: await loadReviewGlossary(baseUrl, sessionId);
|
||||
applyPayload(payload);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await saveReviewGlossary(baseUrl, sessionId, path, entries);
|
||||
applyPayload(payload);
|
||||
setMessage('术语表已保存,下一次重翻会实时使用最新术语。');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<ReviewGlossaryEntry>) => {
|
||||
setEntries((current) =>
|
||||
current.map((entry, itemIndex) => (itemIndex === index ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries((current) => current.filter((_entry, itemIndex) => itemIndex !== index));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setOpen(true);
|
||||
setLoaded(true);
|
||||
setQuery('');
|
||||
setEntries((current) => [{ source: '', target: '' }, ...current]);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-base-200 flex w-full items-center justify-between rounded-md px-3 py-2 text-start'
|
||||
onClick={() => {
|
||||
const nextOpen = !open;
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen && !loaded) void load(false);
|
||||
}}
|
||||
>
|
||||
<span className='text-sm font-semibold'>术语表</span>
|
||||
<span className='text-base-content/60 text-xs'>{open ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='grid gap-3 px-3 pb-3'>
|
||||
<div className='grid gap-2'>
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
placeholder='mingcibiao.json'
|
||||
/>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PanelButton
|
||||
onClick={() => void load(true)}
|
||||
disabled={busy}
|
||||
icon={<RefreshCw className='h-4 w-4' />}
|
||||
>
|
||||
读取
|
||||
</PanelButton>
|
||||
<PanelButton onClick={addEntry} disabled={busy}>
|
||||
新增术语
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={() => void save()}
|
||||
disabled={busy || !loaded || !dirty}
|
||||
icon={<Save className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
保存术语表
|
||||
</PanelButton>
|
||||
</div>
|
||||
<p className='text-base-content/60 text-xs'>{message}</p>
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='搜索术语或译名'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid max-h-72 gap-2 overflow-auto'>
|
||||
{!loaded ? (
|
||||
<p className='text-base-content/60 p-2 text-sm'>展开后会读取术语表。</p>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<p className='text-base-content/60 p-2 text-sm'>没有匹配的术语。</p>
|
||||
) : (
|
||||
filteredEntries.slice(0, 80).map(({ entry, index }) => (
|
||||
<div
|
||||
key={`${index}-${entry.source}`}
|
||||
className='grid grid-cols-[1fr_1fr_auto] gap-2'
|
||||
>
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
value={entry.source}
|
||||
onChange={(event) => updateEntry(index, { source: event.target.value })}
|
||||
placeholder='原文'
|
||||
/>
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||
value={entry.target}
|
||||
onChange={(event) => updateEntry(index, { target: event.target.value })}
|
||||
placeholder='译名'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-2 text-xs'
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const ReviewPanel: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const activeBookKey = useReviewModeStore((state) => state.activeBookKey);
|
||||
const isPanelVisible = useReviewModeStore((state) => state.isPanelVisible);
|
||||
const isPanelPinned = useReviewModeStore((state) => state.isPanelPinned);
|
||||
const panelWidth = useReviewModeStore((state) => state.panelWidth);
|
||||
const setPanelVisible = useReviewModeStore((state) => state.setPanelVisible);
|
||||
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
|
||||
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
|
||||
const selectRow = useReviewModeStore((state) => state.selectRow);
|
||||
const updateRow = useReviewModeStore((state) => state.updateRow);
|
||||
const setBookData = useReviewModeStore((state) => state.setBookData);
|
||||
const bookState = useReviewModeStore((state) =>
|
||||
activeBookKey ? state.books[activeBookKey] : null,
|
||||
);
|
||||
const getBookData = useBookDataStore((state) => state.getBookData);
|
||||
const viewSettings = useReaderStore((state) =>
|
||||
activeBookKey ? state.viewStates[activeBookKey]?.viewSettings : null,
|
||||
);
|
||||
|
||||
const [edit, setEdit] = useState<ReviewEditPayload>(emptyEditState);
|
||||
const [gptForm, setGptForm] = useState<GptForm>(emptyGptForm);
|
||||
const [candidateHtml, setCandidateHtml] = useState('');
|
||||
const [retranslateInstruction, setRetranslateInstruction] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [exportInfo, setExportInfo] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [retranslating, setRetranslating] = useState(false);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => bookState?.rows.find((row) => row.id === bookState.selectedRowId) || null,
|
||||
[bookState?.rows, bookState?.selectedRowId],
|
||||
);
|
||||
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
|
||||
|
||||
const bookTitle = activeBookKey
|
||||
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
|
||||
: '审校';
|
||||
|
||||
useEffect(() => {
|
||||
setGptForm(toGptForm(bookState?.gptConfig));
|
||||
}, [bookState?.gptConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRow) {
|
||||
setEdit(emptyEditState);
|
||||
setCandidateHtml('');
|
||||
setRetranslateInstruction('');
|
||||
return;
|
||||
}
|
||||
setEdit({
|
||||
current_html: selectedRow.current_html || selectedRow.cn_html || '',
|
||||
marked: Boolean(selectedRow.marked),
|
||||
issue_type: selectedRow.issue_type || '',
|
||||
severity: selectedRow.severity || '',
|
||||
tags: selectedRow.tags || '',
|
||||
comment: selectedRow.comment || '',
|
||||
learn_note: selectedRow.learn_note || '',
|
||||
});
|
||||
setCandidateHtml('');
|
||||
setRetranslateInstruction('');
|
||||
}, [selectedRow]);
|
||||
|
||||
const { handleResizeStart, handleResizeKeyDown } = usePanelResize({
|
||||
side: 'end',
|
||||
minWidth: MIN_REVIEW_PANEL_WIDTH,
|
||||
maxWidth: MAX_REVIEW_PANEL_WIDTH,
|
||||
getWidth: () => panelWidth,
|
||||
onResize: setPanelWidth,
|
||||
});
|
||||
|
||||
if (!isPanelVisible || !activeBookKey || !bookState) return null;
|
||||
|
||||
const baseUrl = bookState.baseUrl;
|
||||
const sessionId = bookState.sessionId;
|
||||
const rows = bookState.rows;
|
||||
|
||||
const saveCurrentRow = async () => {
|
||||
if (!selectedRow || !baseUrl) return;
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
|
||||
const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html);
|
||||
const nextRow = {
|
||||
...selectedRow,
|
||||
...edit,
|
||||
current_html: savedHtml,
|
||||
edited: savedHtml !== selectedRow.cn_html,
|
||||
updated_at: payload.updated_at || selectedRow.updated_at,
|
||||
};
|
||||
updateRow(activeBookKey, nextRow);
|
||||
setEdit((current) => ({ ...current, current_html: savedHtml }));
|
||||
const session = await sidecarApi<ReviewSessionPayload>(
|
||||
baseUrl,
|
||||
'/api/session',
|
||||
{},
|
||||
sessionId,
|
||||
);
|
||||
setBookData(activeBookKey, { session });
|
||||
setMessage(`已保存 ${selectedRow.id}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retranslateCurrentRow = async () => {
|
||||
if (!selectedRow || !baseUrl) return;
|
||||
setRetranslating(true);
|
||||
setMessage('');
|
||||
setCandidateHtml('');
|
||||
try {
|
||||
const payload = await retranslateReviewRow(
|
||||
baseUrl,
|
||||
sessionId,
|
||||
selectedRow.id,
|
||||
retranslateInstruction,
|
||||
);
|
||||
setCandidateHtml(payload.candidate_html || '');
|
||||
setMessage(`重翻完成,命中术语 ${payload.glossary_matches?.length || 0} 条。`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setRetranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateFeedback = async () => {
|
||||
if (!baseUrl) return;
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await generateReviewFeedback(baseUrl, sessionId);
|
||||
setMessage(`反馈已生成:${payload.feedback_md || payload.feedback_jsonl || ''}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const exportEpub = async () => {
|
||||
if (!baseUrl) return;
|
||||
setExportInfo('');
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await exportReviewedEpub(baseUrl, sessionId);
|
||||
const downloadUrl = payload.download_url
|
||||
? new URL(payload.download_url, baseUrl).toString()
|
||||
: '';
|
||||
setExportInfo(downloadUrl || payload.output || '');
|
||||
setMessage(`已导出:${payload.output || downloadUrl}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const panelTopInset = getPanelTopInset({
|
||||
isMobile,
|
||||
isFullHeightInMobile: true,
|
||||
systemUIVisible,
|
||||
statusBarHeight,
|
||||
safeAreaInsets,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isPanelPinned && isMobile && (
|
||||
<Overlay
|
||||
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
||||
onDismiss={() => setPanelVisible(false)}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={clsx(
|
||||
'review-panel-container end-0 flex min-w-72 select-none flex-col',
|
||||
'full-height font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
isPanelPinned ? 'z-20' : 'z-[45] shadow-2xl',
|
||||
!isPanelPinned && viewSettings?.isEink && 'border-base-content border-s',
|
||||
isMobile && 'bottom-0 max-h-[92vh] rounded-t-2xl',
|
||||
)}
|
||||
role='group'
|
||||
aria-label='审校'
|
||||
style={{
|
||||
width: isMobile ? '100%' : panelWidth,
|
||||
maxWidth: isMobile ? '100%' : `${MAX_REVIEW_PANEL_WIDTH * 100}%`,
|
||||
position: isMobile ? 'fixed' : isPanelPinned ? 'relative' : 'absolute',
|
||||
paddingTop: `${panelTopInset}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-bar absolute -start-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
|
||||
isMobile && 'hidden',
|
||||
)}
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
aria-label='调整审校面板宽度'
|
||||
aria-orientation='horizontal'
|
||||
aria-valuenow={parseFloat(panelWidth)}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
<header className='border-base-300 flex h-12 shrink-0 items-center gap-2 border-b px-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2 className='truncate text-sm font-semibold'>审校模式</h2>
|
||||
<p className='text-base-content/60 truncate text-xs'>
|
||||
{bookTitle} · {rows.length} 段 · 已编辑 {bookState.session?.touched_count || 0} · 标记{' '}
|
||||
{bookState.session?.marked_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 rounded-full p-0'
|
||||
title={isPanelPinned ? '取消固定' : '固定面板'}
|
||||
onClick={togglePanelPin}
|
||||
>
|
||||
{isPanelPinned ? <PinOff className='h-4 w-4' /> : <Pin className='h-4 w-4' />}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 rounded-full p-0'
|
||||
title={_('Close')}
|
||||
onClick={() => setPanelVisible(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto p-3'>
|
||||
<div className='grid gap-3'>
|
||||
{bookState.loading ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||
正在启动审校器并读取当前 EPUB……
|
||||
</div>
|
||||
) : null}
|
||||
{bookState.error ? (
|
||||
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'>
|
||||
{bookState.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PanelButton
|
||||
onClick={generateFeedback}
|
||||
icon={<FileText className='h-4 w-4' />}
|
||||
disabled={!baseUrl}
|
||||
>
|
||||
生成反馈
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={exportEpub}
|
||||
icon={<Download className='h-4 w-4' />}
|
||||
disabled={!baseUrl}
|
||||
variant='primary'
|
||||
>
|
||||
导出 EPUB
|
||||
</PanelButton>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
{exportInfo ? (
|
||||
<button
|
||||
type='button'
|
||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||
onClick={() => openExternalUrl(exportInfo)}
|
||||
>
|
||||
<span className='font-semibold'>打开导出文件</span>
|
||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||
{exportInfo}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<GptConfigPanel
|
||||
baseUrl={baseUrl}
|
||||
sessionId={sessionId}
|
||||
form={gptForm}
|
||||
setForm={setGptForm}
|
||||
onSaved={(config) => setBookData(activeBookKey, { gptConfig: config })}
|
||||
/>
|
||||
<GlossaryPanel
|
||||
baseUrl={baseUrl}
|
||||
sessionId={sessionId}
|
||||
path={gptForm.glossary_path}
|
||||
setPath={(path) => setGptForm((current) => ({ ...current, glossary_path: path }))}
|
||||
/>
|
||||
|
||||
{rows.length ? (
|
||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
|
||||
<div className='border-base-300 border-b px-3 py-2'>
|
||||
<h3 className='text-sm font-semibold'>段落</h3>
|
||||
<p className='text-base-content/60 text-xs'>
|
||||
在正文中点击日文或中文段落,也会定位到这里。
|
||||
</p>
|
||||
</div>
|
||||
<div className='max-h-48 overflow-auto p-2'>
|
||||
{rows.slice(0, 120).map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type='button'
|
||||
className={clsx(
|
||||
'mb-2 block w-full rounded-md border p-2 text-start text-xs',
|
||||
selectedRow?.id === row.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-base-300 hover:bg-base-200 bg-base-100',
|
||||
)}
|
||||
onClick={() => selectRow(activeBookKey, row.id)}
|
||||
>
|
||||
<span className='flex items-center justify-between gap-2'>
|
||||
<span className='font-mono'>{row.id}</span>
|
||||
<span className='text-base-content/60 truncate'>{rowTitle(row)}</span>
|
||||
</span>
|
||||
<span className='mt-1 line-clamp-1'>{row.jp_text}</span>
|
||||
<span className='text-base-content/70 mt-1 line-clamp-1'>
|
||||
{stripHtml(row.current_html || row.cn_html)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : 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 items-center justify-between gap-2 text-xs'>
|
||||
<span className='truncate'>
|
||||
{selectedRow.id} · {rowTitle(selectedRow)}
|
||||
</span>
|
||||
<span className='shrink-0'>
|
||||
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'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className='grid gap-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
||||
<label className='flex 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>
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-44 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'>
|
||||
<div className='text-base-content/60 mb-2 text-xs'>译文预览</div>
|
||||
<div
|
||||
className='prose prose-sm max-w-none select-text leading-7'
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<input
|
||||
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
|
||||
value={edit.tags}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, tags: event.target.value }))
|
||||
}
|
||||
placeholder='标签'
|
||||
/>
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
|
||||
value={edit.comment}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, comment: event.target.value }))
|
||||
}
|
||||
placeholder='备注'
|
||||
/>
|
||||
<textarea
|
||||
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
|
||||
value={edit.learn_note}
|
||||
onChange={(event) =>
|
||||
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
||||
}
|
||||
placeholder='可沉淀为长期规则'
|
||||
/>
|
||||
<PanelButton
|
||||
onClick={saveCurrentRow}
|
||||
disabled={!canUseRowActions || saving}
|
||||
icon={<Save className='h-4 w-4' />}
|
||||
variant='primary'
|
||||
>
|
||||
{saving ? '保存中' : '保存本段'}
|
||||
</PanelButton>
|
||||
</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 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'
|
||||
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>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewPanel;
|
||||
@@ -366,6 +366,12 @@ async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const openSessionBody = (sessionId: string, activate = true) =>
|
||||
JSON.stringify({ session_id: sessionId, activate });
|
||||
|
||||
const sessionFromPathBody = (epubPath: string, activate = true) =>
|
||||
JSON.stringify({ epub_path: epubPath, activate });
|
||||
|
||||
function readLaunchContextFromBrowser(): LaunchContext {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -1569,7 +1575,7 @@ export default function ReviewEditorLauncher() {
|
||||
if (!baseUrl) return;
|
||||
await sidecarApi<SessionSummary>(baseUrl, '/api/session/open', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: nextSessionId }),
|
||||
body: openSessionBody(nextSessionId),
|
||||
});
|
||||
setSessionId(nextSessionId);
|
||||
setActiveMode(mode);
|
||||
@@ -1605,7 +1611,7 @@ export default function ReviewEditorLauncher() {
|
||||
'/api/session/from-path',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ epub_path: context.epubPath }),
|
||||
body: sessionFromPathBody(context.epubPath),
|
||||
},
|
||||
);
|
||||
nextSessionId = createdSession.id || null;
|
||||
@@ -1617,7 +1623,7 @@ export default function ReviewEditorLauncher() {
|
||||
if (nextSessionId) {
|
||||
await sidecarApi<SessionSummary>(data.url, '/api/session/open', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: nextSessionId }),
|
||||
body: openSessionBody(nextSessionId),
|
||||
});
|
||||
}
|
||||
setPayload(data);
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import type { Book } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
export type ReviewEditorLaunchResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
sessionId?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ReviewSessionSummary = {
|
||||
id: string;
|
||||
source_name?: string;
|
||||
title?: string;
|
||||
source_epub?: string;
|
||||
row_count?: number;
|
||||
touched_count?: number;
|
||||
marked_count?: number;
|
||||
feedback_md?: string;
|
||||
feedback_jsonl?: string;
|
||||
latest_export?: string;
|
||||
};
|
||||
|
||||
export type ReviewSessionOpenResponse = ReviewSessionSummary & {
|
||||
has_session?: boolean;
|
||||
};
|
||||
|
||||
export type ReviewSessionPayload = {
|
||||
has_session: boolean;
|
||||
id?: string;
|
||||
title?: string;
|
||||
source_name?: string;
|
||||
source_epub?: string;
|
||||
row_count?: number;
|
||||
touched_count?: number;
|
||||
marked_count?: number;
|
||||
feedback_md?: string;
|
||||
feedback_jsonl?: string;
|
||||
review_root?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ReviewRow = {
|
||||
id: string;
|
||||
file: string;
|
||||
file_label?: string;
|
||||
document_title?: string;
|
||||
file_order?: number;
|
||||
file_row_index?: number;
|
||||
ja_p_index?: number;
|
||||
cn_p_index?: number;
|
||||
jp_html: string;
|
||||
jp_text: string;
|
||||
cn_html: string;
|
||||
cn_text?: string;
|
||||
current_html: string;
|
||||
marked?: boolean;
|
||||
issue_type?: string;
|
||||
severity?: string;
|
||||
comment?: string;
|
||||
learn_note?: string;
|
||||
tags?: string;
|
||||
edited?: boolean;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ReviewRowsPayload = {
|
||||
rows: ReviewRow[];
|
||||
};
|
||||
|
||||
export type ReviewGptConfig = {
|
||||
configured?: boolean;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
key_source?: string;
|
||||
updated_at?: string;
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
glossary_path?: string;
|
||||
prompt_defaults?: {
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReviewEditPayload = {
|
||||
current_html: string;
|
||||
marked: boolean;
|
||||
issue_type: string;
|
||||
severity: string;
|
||||
tags: string;
|
||||
comment: string;
|
||||
learn_note: string;
|
||||
};
|
||||
|
||||
export type ReviewSaveRowPayload = {
|
||||
status?: string;
|
||||
row_id?: string;
|
||||
updated_at?: string;
|
||||
current_html?: string;
|
||||
};
|
||||
|
||||
export type ReviewGlossaryEntry = {
|
||||
source: string;
|
||||
target: string;
|
||||
clean_target?: string;
|
||||
};
|
||||
|
||||
export type ReviewGlossaryPayload = {
|
||||
path?: string;
|
||||
exists?: boolean;
|
||||
count?: number;
|
||||
entries?: ReviewGlossaryEntry[];
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ReviewBootstrapPayload = {
|
||||
session: ReviewSessionPayload;
|
||||
rows: ReviewRow[];
|
||||
gptConfig: ReviewGptConfig;
|
||||
};
|
||||
|
||||
export type ReviewSessionFromPathOptions = {
|
||||
activate?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
const ensureBaseUrl = (baseUrl: string) => {
|
||||
if (!baseUrl) throw new Error('审校器后端尚未启动');
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
};
|
||||
|
||||
export async function sidecarApi<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
sessionId?: string | null,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, ensureBaseUrl(baseUrl));
|
||||
if (sessionId) {
|
||||
url.searchParams.set('session_id', sessionId);
|
||||
}
|
||||
const headers =
|
||||
options.body === undefined
|
||||
? options.headers
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url.toString(), {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String(data.error || `HTTP ${response.status}`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function createReviewSessionFromPath(
|
||||
baseUrl: string,
|
||||
epubPath: string,
|
||||
options: ReviewSessionFromPathOptions = {},
|
||||
): Promise<ReviewSessionSummary> {
|
||||
return sidecarApi(baseUrl, '/api/session/from-path', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
epub_path: epubPath,
|
||||
activate: options.activate ?? false,
|
||||
reset: options.reset ?? false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function openReviewSession(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
options: { activate?: boolean } = {},
|
||||
): Promise<ReviewSessionOpenResponse> {
|
||||
return sidecarApi(baseUrl, '/api/session/open', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
activate: options.activate ?? false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchInlineReviewEditor(
|
||||
appService: AppService | null | undefined,
|
||||
book: Book,
|
||||
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
|
||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||
|
||||
const epubPath = await appService.resolveNativeBookFilePath(book);
|
||||
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||
|
||||
const launch = isTauriAppPlatform()
|
||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
||||
epubPath,
|
||||
})
|
||||
: await fetch('/api/review-editor/launch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-readest-review-editor-launch': '1',
|
||||
},
|
||||
}).then((response) => response.json() as Promise<ReviewEditorLaunchResponse>);
|
||||
|
||||
if (!launch.ok || !launch.url) {
|
||||
throw new Error(launch.error || '审校器启动失败');
|
||||
}
|
||||
|
||||
let sessionId = launch.sessionId || null;
|
||||
if (!sessionId) {
|
||||
const createdSession = await createReviewSessionFromPath(launch.url, epubPath);
|
||||
sessionId = createdSession.id || null;
|
||||
}
|
||||
|
||||
return { ...launch, url: launch.url, sessionId };
|
||||
}
|
||||
|
||||
export async function loadInlineReviewData(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
): Promise<ReviewBootstrapPayload> {
|
||||
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
|
||||
const [session, rowsPayload, gptConfig] = await Promise.all([
|
||||
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
|
||||
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
|
||||
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
rows: rowsPayload.rows || [],
|
||||
gptConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveReviewRow(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
rowId: string,
|
||||
edit: ReviewEditPayload,
|
||||
): Promise<ReviewSaveRowPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/row/${encodeURIComponent(rowId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(edit),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function retranslateReviewRow(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
rowId: string,
|
||||
instruction: string,
|
||||
): Promise<{
|
||||
candidate_html?: string;
|
||||
candidate_text?: string;
|
||||
glossary_matches?: string[];
|
||||
}> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/row/${encodeURIComponent(rowId)}/retranslate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ instruction }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveReviewGptConfig(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
config: {
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
glossary_path?: string;
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
},
|
||||
): Promise<ReviewGptConfig> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/gpt/config',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...config,
|
||||
keep_existing: true,
|
||||
}),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadReviewGlossary(
|
||||
baseUrl: string,
|
||||
sessionId?: string | null,
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(baseUrl, '/api/glossary', {}, sessionId);
|
||||
}
|
||||
|
||||
export async function switchReviewGlossaryPath(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/glossary',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveReviewGlossary(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
path: string,
|
||||
entries: ReviewGlossaryEntry[],
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/glossary',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, entries }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateReviewFeedback(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
): Promise<{ feedback_md?: string; feedback_jsonl?: string }> {
|
||||
return sidecarApi(baseUrl, '/api/feedback', {}, sessionId);
|
||||
}
|
||||
|
||||
export async function exportReviewedEpub(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
): Promise<{ output?: string; download_url?: string }> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/export',
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type {
|
||||
ReviewGptConfig,
|
||||
ReviewRow,
|
||||
ReviewSessionPayload,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
export type ReviewBookState = {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
baseUrl: string;
|
||||
sessionId: string | null;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
rows: ReviewRow[];
|
||||
session: ReviewSessionPayload | null;
|
||||
gptConfig: ReviewGptConfig | null;
|
||||
selectedRowId: string;
|
||||
};
|
||||
|
||||
type ReviewModeStore = {
|
||||
activeBookKey: string | null;
|
||||
isPanelVisible: boolean;
|
||||
isPanelPinned: boolean;
|
||||
panelWidth: string;
|
||||
books: Record<string, ReviewBookState>;
|
||||
getBookState: (bookKey: string | null) => ReviewBookState | null;
|
||||
setActiveBookKey: (bookKey: string | null) => void;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
togglePanelPin: () => void;
|
||||
setPanelWidth: (width: string) => void;
|
||||
setBookLoading: (bookKey: string, loading: boolean) => void;
|
||||
setBookError: (bookKey: string, error: string) => void;
|
||||
setBookEnabled: (bookKey: string, enabled: boolean) => void;
|
||||
setBookData: (
|
||||
bookKey: string,
|
||||
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
|
||||
) => void;
|
||||
selectRow: (bookKey: string, rowId: string) => void;
|
||||
updateRow: (bookKey: string, row: ReviewRow) => void;
|
||||
clearBook: (bookKey: string) => void;
|
||||
};
|
||||
|
||||
const emptyBookState = (): ReviewBookState => ({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
baseUrl: '',
|
||||
sessionId: null,
|
||||
rows: [],
|
||||
session: null,
|
||||
gptConfig: null,
|
||||
selectedRowId: '',
|
||||
});
|
||||
|
||||
const withBookState = (
|
||||
books: Record<string, ReviewBookState>,
|
||||
bookKey: string,
|
||||
patch: Partial<ReviewBookState>,
|
||||
) => ({
|
||||
...books,
|
||||
[bookKey]: {
|
||||
...(books[bookKey] || emptyBookState()),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewModeStore = create<ReviewModeStore>((set, get) => ({
|
||||
activeBookKey: null,
|
||||
isPanelVisible: false,
|
||||
isPanelPinned: false,
|
||||
panelWidth: '32%',
|
||||
books: {},
|
||||
getBookState: (bookKey) => (bookKey ? get().books[bookKey] || null : null),
|
||||
setActiveBookKey: (bookKey) => set({ activeBookKey: bookKey }),
|
||||
setPanelVisible: (visible) => set({ isPanelVisible: visible }),
|
||||
togglePanelPin: () => set((state) => ({ isPanelPinned: !state.isPanelPinned })),
|
||||
setPanelWidth: (width) => set({ panelWidth: width }),
|
||||
setBookLoading: (bookKey, loading) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, loading ? { loading, error: '' } : { loading }),
|
||||
})),
|
||||
setBookError: (bookKey, error) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, { error, loading: false }),
|
||||
})),
|
||||
setBookEnabled: (bookKey, enabled) =>
|
||||
set((state) => ({
|
||||
activeBookKey: enabled
|
||||
? bookKey
|
||||
: state.activeBookKey === bookKey
|
||||
? null
|
||||
: state.activeBookKey,
|
||||
isPanelVisible: enabled
|
||||
? true
|
||||
: state.activeBookKey === bookKey
|
||||
? false
|
||||
: state.isPanelVisible,
|
||||
books: withBookState(state.books, bookKey, { enabled }),
|
||||
})),
|
||||
setBookData: (bookKey, data) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, {
|
||||
...data,
|
||||
loading: false,
|
||||
error: '',
|
||||
}),
|
||||
})),
|
||||
selectRow: (bookKey, rowId) =>
|
||||
set((state) => ({
|
||||
activeBookKey: bookKey,
|
||||
isPanelVisible: true,
|
||||
books: withBookState(state.books, bookKey, {
|
||||
selectedRowId: rowId,
|
||||
}),
|
||||
})),
|
||||
updateRow: (bookKey, row) =>
|
||||
set((state) => {
|
||||
const current = state.books[bookKey] || emptyBookState();
|
||||
return {
|
||||
books: withBookState(state.books, bookKey, {
|
||||
rows: current.rows.map((item) => (item.id === row.id ? row : item)),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
clearBook: (bookKey) =>
|
||||
set((state) => {
|
||||
const books = { ...state.books };
|
||||
delete books[bookKey];
|
||||
return {
|
||||
books,
|
||||
activeBookKey: state.activeBookKey === bookKey ? null : state.activeBookKey,
|
||||
isPanelVisible: state.activeBookKey === bookKey ? false : state.isPanelVisible,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -51,6 +51,7 @@ epub_review_sessions/
|
||||
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
|
||||
- Readest 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 session,Readest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
|
||||
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecar,sidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。
|
||||
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows` 与 `/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),不得迁入整书翻译、书架管理等无关功能。
|
||||
|
||||
## 目录与插图
|
||||
|
||||
@@ -94,6 +95,7 @@ epub_review_sessions/
|
||||
- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。
|
||||
- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。
|
||||
- 点击正文段落打开右侧精修与重翻面板。
|
||||
- 在 Readest 原生阅读页启用“校”审校模式后,点击或选中 Foliate 正文里的日文源段或中文译文段必须打开同一条审校记录;未开启审校模式时不得改变普通阅读、翻页、标注或笔记行为。
|
||||
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
|
||||
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
|
||||
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
|
||||
@@ -105,6 +107,8 @@ epub_review_sessions/
|
||||
- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。
|
||||
- 记录 `before_cn_html`、`after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。
|
||||
- 更新 `feedback/translation_feedback.jsonl`。
|
||||
- `/api/row/<row_id>` 保存响应应返回服务端清洗后的 `current_html`,原生 Readest 面板和 Foliate 审校标记必须以后端规范化结果或同等前端 sanitize 结果更新本地 store,不能把用户输入的原始 HTML 直接写回正文 iframe。
|
||||
- 原生 Readest 阅读页的 inline 审校 API 调用必须携带当前书的 `session_id`;`/api/session`、`/api/rows`、`/api/row/<row_id>`、`/api/row/<row_id>/retranslate`、`/api/feedback`、`/api/export` 与导出下载都应支持 session-scoped 调用,不能依赖 sidecar 全局 active session,以免多书格子或 `/review-editor` 页面切换会话时串写。
|
||||
|
||||
生成反馈时必须:
|
||||
|
||||
@@ -242,6 +246,8 @@ Prompt 硬规则:
|
||||
- `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页
|
||||
- Readest 桌面端 `invoke('launch_epub_review_editor')` 能启动或复用 sidecar,成功时返回 sidecar URL、版本、review root 和可选 session id;dev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用
|
||||
- Readest `/review-editor` 页面必须提供“校对”“翻译”两个原生 React 功能块,切换后直接调用 sidecar API,不得把旧静态审校器 iframe 作为主工作区;旧网页只能作为外部打开/调试 fallback
|
||||
- Readest 原生阅读页顶栏“校”按钮必须能启动/复用 sidecar,当前 EPUB 的中日双语段落必须能被 Foliate iframe 内高亮,并能通过点击或文本选择打开右侧审校面板;关闭审校模式后高亮、事件监听与临时译文预览必须清理
|
||||
- Readest 原生阅读页右侧审校面板必须支持保存 `/api/row/<row_id>`、重翻 `/api/row/<row_id>/retranslate`、配置 `/api/gpt/config`、读取/保存 `/api/glossary`、生成 `/api/feedback` 与导出 `/api/export`
|
||||
- 校对功能块至少能加载 `/api/rows` 与 `/api/structure`,选择行,保存 `/api/row/<row_id>`,调用 `/api/row/<row_id>/retranslate`,生成反馈 `/api/feedback`,并导出 `/api/export`
|
||||
- 翻译功能块至少能读取 `/api/session/<session_id>/translation-source` 与 `/api/translation/defaults`,保存 `/api/gpt/config`,保存同系列 `/api/series/<series_id>/config`,启动 `/api/translation/start`,轮询 `/api/translation/jobs/<job_id>`,完成后能打开输出 session
|
||||
- Readest 书架 EPUB 右键进入校对/翻译时,Tauri command 应先创建或复用对应 session,并将 `/review-editor` 地址整理为 `session_id`,不把本机 EPUB 路径长期留在页面 URL 中
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 双语 EPUB 审校编辑器
|
||||
|
||||
当前版本:`0.15.0`
|
||||
当前版本:`0.16.0`
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
|
||||
`0.15.0` 将 Readest `/review-editor` 迁移为原生 React 桌面功能块:校对和翻译不再以旧网页 iframe 作为主界面,而是直接调用本地 sidecar API 完成行加载、保存、重翻、反馈、导出、翻译配置、任务启动和进度轮询。旧静态网页界面保留为独立启动与调试 fallback。sidecar 为本机 Readest/Tauri 来源增加受限 CORS,以支持原生功能块直接访问 loopback API。
|
||||
|
||||
`0.16.0` 新增 Readest 原生阅读页审校模式:桌面端阅读界面顶栏提供“校”按钮,启用后会启动或复用本地 sidecar,并在 Foliate 原生正文里标记中日双语段落;点击或选中日文/中文段落会打开右侧审校面板,可直接修改中文译文、标记问题类型/严重程度/标签/备注/长期规则,配置 API 与术语表,执行单段重翻、生成反馈并导出修订 EPUB。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
@@ -101,6 +103,8 @@ apps/readest-app\epub_review_sessions\
|
||||
|
||||
桌面端会通过 Tauri command 启动本地 sidecar;dev-web 仍保留 `/api/review-editor/launch` 作为开发入口。Readest `/review-editor` 页面使用 React 原生“校对”“翻译”功能块直接调用 sidecar API;旧静态网页界面仅作为独立运行和排查问题时的 fallback。
|
||||
|
||||
在 Readest 桌面端原生阅读界面中,打开 EPUB 后也可以点击顶栏“校”按钮进入内嵌审校模式。该模式不离开阅读页,只给当前书的双语段落加审校标记,并把旧审校器右侧栏能力收敛到阅读页右侧面板。
|
||||
|
||||
## 独立启动
|
||||
|
||||
如果只想脱离 Readest 单独运行本工具,可以在本目录执行:
|
||||
@@ -161,6 +165,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
|
||||
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
|
||||
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
|
||||
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
|
||||
- 在 Readest 原生阅读界面中可点击顶栏“校”启用内嵌审校模式;中文和日文段落会在正文中高亮可选,点击或选中任一侧都会打开同一段的审校面板。
|
||||
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
|
||||
- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url`、`model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
|
||||
|
||||
@@ -105,7 +105,7 @@ DEFAULT_TRANSLATION_LIMIT = 20
|
||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||
TRANSLATION_CONTEXT_RADIUS = 1
|
||||
LIBRARY_DB_NAME = "library.json"
|
||||
ROWS_PARSER_VERSION = "0.13.1-translation-pair-cleanup"
|
||||
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -239,6 +239,14 @@ def is_gray_source(attrs: str, inner: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_inline_review_source_paragraph(open_tag: str, inner: str) -> bool:
|
||||
attrs = open_tag[2:-1]
|
||||
return is_gray_source(attrs, inner) or (
|
||||
bool(re.search(r"\bopacity\s*:\s*0?\.[0-9]+", attrs, flags=re.I))
|
||||
and is_japaneseish(inner)
|
||||
)
|
||||
|
||||
|
||||
def paragraph_visible_text(inner: str) -> str:
|
||||
return compact_text(strip_tags(inner or ""))
|
||||
|
||||
@@ -541,6 +549,55 @@ def public_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def normalize_gpt_config(data: dict[str, Any]) -> dict[str, Any]:
|
||||
api_key = str(data.get("api_key") or "").strip()
|
||||
prompts = {
|
||||
name: normalize_gpt_prompt(name, data.get(name, default))
|
||||
for name, default in GPT_PROMPT_DEFAULTS.items()
|
||||
}
|
||||
return {
|
||||
"base_url": normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL)),
|
||||
"model": str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL,
|
||||
"api_key": api_key,
|
||||
"configured": bool(api_key),
|
||||
"key_source": str(data.get("key_source") or ""),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or "")),
|
||||
**prompts,
|
||||
}
|
||||
|
||||
|
||||
def session_gpt_config_path(session_root: Path) -> Path:
|
||||
return session_root / "review_state" / "gpt_config.json"
|
||||
|
||||
|
||||
def read_scoped_gpt_config(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
|
||||
config = read_gpt_config(review_root)
|
||||
if session_root is not None:
|
||||
scoped = read_json(session_gpt_config_path(session_root), {})
|
||||
if isinstance(scoped, dict):
|
||||
config.update({key: value for key, value in scoped.items() if key != "api_key"})
|
||||
return normalize_gpt_config(config)
|
||||
|
||||
|
||||
def public_scoped_gpt_config(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
|
||||
if session_root is None:
|
||||
return public_gpt_config(review_root)
|
||||
config = read_scoped_gpt_config(review_root, session_root)
|
||||
return {
|
||||
"configured": config["configured"],
|
||||
"base_url": config["base_url"],
|
||||
"model": config["model"],
|
||||
"key_source": config["key_source"],
|
||||
"updated_at": config["updated_at"],
|
||||
"translation_prompt": config["translation_prompt"],
|
||||
"format_prompt": config["format_prompt"],
|
||||
"character_prompt": config["character_prompt"],
|
||||
"glossary_path": config["glossary_path"],
|
||||
"prompt_defaults": gpt_prompt_defaults(),
|
||||
}
|
||||
|
||||
|
||||
def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
base_url = normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||
model = str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL
|
||||
@@ -568,14 +625,47 @@ def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
return public_gpt_config(review_root)
|
||||
|
||||
|
||||
def glossary_path(review_root: Path) -> Path:
|
||||
def save_scoped_gpt_config(
|
||||
review_root: Path, data: dict[str, Any], session_root: Path | None = None
|
||||
) -> dict[str, Any]:
|
||||
if session_root is None:
|
||||
return save_gpt_config(review_root, data)
|
||||
current = read_scoped_gpt_config(review_root, session_root)
|
||||
base_url = normalize_gpt_base_url(str(data.get("base_url") or current["base_url"]))
|
||||
model = str(data.get("model") or current["model"]).strip() or DEFAULT_GPT_MODEL
|
||||
api_key = str(data.get("api_key") or "").strip()
|
||||
if api_key:
|
||||
update_gpt_config_fields(review_root, {"api_key": api_key})
|
||||
prompts = {}
|
||||
for key in ("translation_prompt", "format_prompt", "character_prompt"):
|
||||
value = data.get(key) if key in data else current.get(key)
|
||||
prompts[key] = normalize_gpt_prompt(key, value)
|
||||
record = {
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
"glossary_path": normalize_glossary_path_value(
|
||||
str(data.get("glossary_path") or current["glossary_path"] or "")
|
||||
),
|
||||
**prompts,
|
||||
"updated_at": now_iso(),
|
||||
}
|
||||
write_json(session_gpt_config_path(session_root), record)
|
||||
return public_scoped_gpt_config(review_root, session_root)
|
||||
|
||||
|
||||
def glossary_path(review_root: Path, session_root: Path | None = None) -> Path:
|
||||
if session_root is not None:
|
||||
scoped = read_json(session_gpt_config_path(session_root), {})
|
||||
configured = str(scoped.get("glossary_path") or "").strip()
|
||||
if configured:
|
||||
return Path(normalize_glossary_path_value(configured))
|
||||
data = read_json(gpt_config_path(review_root), {})
|
||||
configured = str(data.get("glossary_path") or "").strip()
|
||||
return Path(normalize_glossary_path_value(configured))
|
||||
|
||||
|
||||
def read_glossary(review_root: Path) -> dict[str, str]:
|
||||
path = glossary_path(review_root)
|
||||
def read_glossary(review_root: Path, session_root: Path | None = None) -> dict[str, str]:
|
||||
path = glossary_path(review_root, session_root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
@@ -591,9 +681,9 @@ def read_glossary(review_root: Path) -> dict[str, str]:
|
||||
return glossary
|
||||
|
||||
|
||||
def public_glossary(review_root: Path) -> dict[str, Any]:
|
||||
path = glossary_path(review_root)
|
||||
glossary = read_glossary(review_root)
|
||||
def public_glossary(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
|
||||
path = glossary_path(review_root, session_root)
|
||||
glossary = read_glossary(review_root, session_root)
|
||||
entries = [
|
||||
{
|
||||
"source": key,
|
||||
@@ -611,8 +701,10 @@ def public_glossary(review_root: Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
path = glossary_path(review_root)
|
||||
def save_glossary(
|
||||
review_root: Path, entries: list[dict[str, Any]], session_root: Path | None = None
|
||||
) -> dict[str, Any]:
|
||||
path = glossary_path(review_root, session_root)
|
||||
if path.exists() and not path.is_file():
|
||||
raise ValueError("术语表路径不是文件")
|
||||
glossary: dict[str, str] = {}
|
||||
@@ -634,7 +726,7 @@ def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str,
|
||||
glossary[source] = target
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_json(path, glossary)
|
||||
return public_glossary(review_root)
|
||||
return public_glossary(review_root, session_root)
|
||||
|
||||
|
||||
def search_glossary_entries(glossary: dict[str, str], term: str, limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
|
||||
@@ -688,9 +780,14 @@ def glossary_prompt_line(key: str, value: str, source: str) -> str:
|
||||
return f"{key} => {translation}"
|
||||
|
||||
|
||||
def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_PROMPT_LIMIT) -> list[str]:
|
||||
def glossary_matches_for_prompt(
|
||||
review_root: Path,
|
||||
row: dict[str, Any],
|
||||
limit: int = GLOSSARY_PROMPT_LIMIT,
|
||||
session_root: Path | None = None,
|
||||
) -> list[str]:
|
||||
try:
|
||||
glossary = read_glossary(review_root)
|
||||
glossary = read_glossary(review_root, session_root)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -2401,7 +2498,9 @@ def build_rows(session_root: Path) -> list[dict[str, Any]]:
|
||||
cn_inner = matches[i + 1].group(2)
|
||||
next_open = matches[i + 1].group(1)
|
||||
next_attrs = next_open[2:-1]
|
||||
if is_gray_source(attrs, jp_inner) and not is_gray_source(next_attrs, cn_inner):
|
||||
if is_inline_review_source_paragraph(open_tag, jp_inner) and not is_inline_review_source_paragraph(
|
||||
next_open, cn_inner
|
||||
):
|
||||
serial += 1
|
||||
file_serial += 1
|
||||
rows.append(
|
||||
@@ -3171,6 +3270,18 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
|
||||
return None, None
|
||||
|
||||
def session_from_request() -> tuple[Path | None, Path | None]:
|
||||
session_id = str(request.args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return active_session()
|
||||
try:
|
||||
session_root = session_root_from_id(review_root, session_id)
|
||||
except ValueError:
|
||||
return None, None
|
||||
if not (session_root / "review_state" / "state.json").exists() or session_is_hidden(session_root):
|
||||
return None, None
|
||||
return session_root, session_epub_path(session_root)
|
||||
|
||||
def no_active_response():
|
||||
return jsonify(
|
||||
{
|
||||
@@ -3234,7 +3345,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/session")
|
||||
def api_session():
|
||||
session_root, epub_path = active_session()
|
||||
session_root, epub_path = session_from_request()
|
||||
if session_root is None or epub_path is None:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -3335,6 +3446,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
def api_open_session():
|
||||
data = request.get_json(silent=True) or {}
|
||||
session_id = str(data.get("session_id", ""))
|
||||
activate = bool(data.get("activate", True))
|
||||
if not session_id:
|
||||
return jsonify({"error": "缺少 session_id"}), 400
|
||||
try:
|
||||
@@ -3345,8 +3457,9 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||
if session_is_hidden(session_root):
|
||||
return jsonify({"error": "会话已从书架移除"}), 404
|
||||
with app.config["LOCK"]:
|
||||
set_active_session(session_root)
|
||||
if activate:
|
||||
with app.config["LOCK"]:
|
||||
set_active_session(session_root)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
@@ -3365,10 +3478,12 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
reset_session = bool(data.get("reset", False))
|
||||
activate = bool(data.get("activate", True))
|
||||
with app.config["LOCK"]:
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
ensure_state(session_root, epub_path, reset=reset_session)
|
||||
set_active_session(session_root, epub_path)
|
||||
if activate:
|
||||
set_active_session(session_root, epub_path)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
@@ -3394,21 +3509,21 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/rows")
|
||||
def api_rows():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return jsonify({"rows": merge_rows(session_root)})
|
||||
|
||||
@app.route("/api/structure")
|
||||
def api_structure():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return jsonify(build_structure(session_root))
|
||||
|
||||
@app.route("/api/reading-position", methods=["GET", "POST"])
|
||||
def api_reading_position():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
if request.method == "GET":
|
||||
@@ -3430,7 +3545,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/asset/<path:entry_name>")
|
||||
def api_asset(entry_name: str):
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
entry_name = urllib.parse.unquote(entry_name)
|
||||
@@ -3479,12 +3594,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/gpt/config", methods=["GET", "POST"])
|
||||
def api_gpt_config():
|
||||
session_root, _epub_path = session_from_request()
|
||||
if request.method == "GET":
|
||||
return jsonify(public_gpt_config(review_root))
|
||||
return jsonify(public_scoped_gpt_config(review_root, session_root))
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
if data.get("reset_prompts"):
|
||||
current = read_gpt_config(review_root)
|
||||
current = read_scoped_gpt_config(review_root, session_root)
|
||||
data = {
|
||||
**data,
|
||||
"base_url": data.get("base_url") or current["base_url"],
|
||||
@@ -3492,39 +3608,48 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"keep_existing": data.get("keep_existing", True),
|
||||
**gpt_prompt_defaults(),
|
||||
}
|
||||
return jsonify(save_gpt_config(review_root, data))
|
||||
return jsonify(save_scoped_gpt_config(review_root, data, session_root))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary", methods=["GET", "POST"])
|
||||
def api_glossary():
|
||||
session_root, _epub_path = session_from_request()
|
||||
if request.method == "GET":
|
||||
try:
|
||||
return jsonify(public_glossary(review_root))
|
||||
return jsonify(public_glossary(review_root, session_root))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
requested_path = str(data.get("path") or "").strip()
|
||||
if requested_path:
|
||||
update_gpt_config_fields(review_root, {"glossary_path": normalize_glossary_path_value(requested_path)})
|
||||
path_value = normalize_glossary_path_value(requested_path)
|
||||
if session_root is None:
|
||||
update_gpt_config_fields(review_root, {"glossary_path": path_value})
|
||||
else:
|
||||
current = read_json(session_gpt_config_path(session_root), {})
|
||||
current["glossary_path"] = path_value
|
||||
current["updated_at"] = now_iso()
|
||||
write_json(session_gpt_config_path(session_root), current)
|
||||
if "entries" not in data:
|
||||
return jsonify(public_glossary(review_root))
|
||||
return jsonify(public_glossary(review_root, session_root))
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
return jsonify({"error": "entries 必须是数组"}), 400
|
||||
return jsonify(save_glossary(review_root, entries))
|
||||
return jsonify(save_glossary(review_root, entries, session_root))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary/entry", methods=["POST", "DELETE"])
|
||||
def api_glossary_entry():
|
||||
session_root, _epub_path = session_from_request()
|
||||
data = request.get_json(silent=True) or {}
|
||||
source = str(data.get("source") or "").strip()
|
||||
if not source:
|
||||
return jsonify({"error": "缺少术语原文"}), 400
|
||||
try:
|
||||
glossary = read_glossary(review_root)
|
||||
glossary = read_glossary(review_root, session_root)
|
||||
if request.method == "DELETE":
|
||||
if source not in glossary:
|
||||
return jsonify({"error": "术语不存在"}), 404
|
||||
@@ -3537,15 +3662,22 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
if old_source and old_source != source:
|
||||
glossary.pop(old_source, None)
|
||||
glossary[source] = target
|
||||
return jsonify(save_glossary(review_root, [{"source": key, "target": value} for key, value in glossary.items()]))
|
||||
return jsonify(
|
||||
save_glossary(
|
||||
review_root,
|
||||
[{"source": key, "target": value} for key, value in glossary.items()],
|
||||
session_root,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary/search")
|
||||
def api_glossary_search():
|
||||
session_root, _epub_path = session_from_request()
|
||||
term = str(request.args.get("q") or "")
|
||||
try:
|
||||
matches = search_glossary_entries(read_glossary(review_root), term)
|
||||
matches = search_glossary_entries(read_glossary(review_root, session_root), term)
|
||||
return jsonify({"query": term, "count": len(matches), "matches": matches})
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
@@ -3696,7 +3828,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||
def api_retranslate_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
rows = merge_rows(session_root)
|
||||
@@ -3705,7 +3837,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify({"error": "row not found"}), 404
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
config = read_gpt_config(review_root)
|
||||
config = read_scoped_gpt_config(review_root, session_root)
|
||||
requested_base_url = str(data.get("base_url") or "").strip()
|
||||
requested_model = str(data.get("model") or "").strip()
|
||||
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
|
||||
@@ -3713,12 +3845,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
if requested_model and requested_model != config["model"]:
|
||||
return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400
|
||||
instruction = str(data.get("instruction") or "")
|
||||
glossary_lines = glossary_matches_for_prompt(review_root, row)
|
||||
glossary_lines = glossary_matches_for_prompt(review_root, row, session_root=session_root)
|
||||
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, rows, config, glossary_lines, instruction))
|
||||
candidate_html = sanitize_cn_html(restore_glossary_ruby_candidates(normalize_candidate_punctuation(raw), glossary_lines))
|
||||
if not candidate_html:
|
||||
return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502
|
||||
candidate_text = strip_tags(candidate_html)
|
||||
resolved_glossary_path = str(glossary_path(review_root, session_root))
|
||||
record = {
|
||||
"ts": now_iso(),
|
||||
"event": "row_retranslated",
|
||||
@@ -3730,7 +3863,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"translation_prompt": config.get("translation_prompt", ""),
|
||||
"format_prompt": config.get("format_prompt", ""),
|
||||
"character_prompt": config.get("character_prompt", ""),
|
||||
"glossary_path": str(glossary_path(review_root)),
|
||||
"glossary_path": resolved_glossary_path,
|
||||
"glossary_matches": glossary_lines,
|
||||
"instruction": instruction[:1200],
|
||||
"jp_text": row["jp_text"],
|
||||
@@ -3745,7 +3878,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"candidate_html": candidate_html,
|
||||
"candidate_text": candidate_text,
|
||||
"model": config["model"],
|
||||
"glossary_path": str(glossary_path(review_root)),
|
||||
"glossary_path": resolved_glossary_path,
|
||||
"glossary_matches": glossary_lines,
|
||||
"updated_at": record["ts"],
|
||||
}
|
||||
@@ -3757,11 +3890,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/row/<row_id>", methods=["POST"])
|
||||
def api_save_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
data = request.get_json(silent=True) or {}
|
||||
rows = read_json(session_root / "review_state" / "rows.json", [])
|
||||
rows = ensure_rows_current(session_root)
|
||||
base = next((row for row in rows if row["id"] == row_id), None)
|
||||
if base is None:
|
||||
return jsonify({"error": "row not found"}), 404
|
||||
@@ -3804,11 +3937,18 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"after_cn_html": current_html,
|
||||
},
|
||||
)
|
||||
return jsonify({"status": "ok", "row_id": row_id, "updated_at": edit["updated_at"]})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"row_id": row_id,
|
||||
"updated_at": edit["updated_at"],
|
||||
"current_html": current_html,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/apply", methods=["POST"])
|
||||
def api_apply():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
with app.config["LOCK"]:
|
||||
@@ -3823,7 +3963,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
|
||||
@app.route("/api/export", methods=["POST"])
|
||||
def api_export():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
with app.config["LOCK"]:
|
||||
@@ -3832,36 +3972,40 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
{
|
||||
"status": "ok",
|
||||
"output": str(out_path),
|
||||
"download_url": f"/downloads/export/{urllib.parse.quote(out_path.name)}",
|
||||
"download_url": (
|
||||
f"/downloads/export/{urllib.parse.quote(out_path.name)}"
|
||||
f"?session_id={urllib.parse.quote(session_public_id(session_root))}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/feedback")
|
||||
def api_feedback():
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
path = write_feedback_summary(session_root)
|
||||
session_query = f"?session_id={urllib.parse.quote(session_public_id(session_root))}"
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"feedback_md": str(path),
|
||||
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
|
||||
"feedback_md_url": "/downloads/feedback/feedback_for_codex.md",
|
||||
"feedback_jsonl_url": "/downloads/feedback/translation_feedback.jsonl",
|
||||
"feedback_md_url": f"/downloads/feedback/feedback_for_codex.md{session_query}",
|
||||
"feedback_jsonl_url": f"/downloads/feedback/translation_feedback.jsonl{session_query}",
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/downloads/export/<path:filename>")
|
||||
def download_export(filename: str):
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return send_from_directory(session_root / "exports", filename, as_attachment=True)
|
||||
|
||||
@app.route("/downloads/feedback/<path:filename>")
|
||||
def download_feedback(filename: str):
|
||||
session_root, _epub_path = active_session()
|
||||
session_root, _epub_path = session_from_request()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
if filename not in {"feedback_for_codex.md", "translation_feedback.jsonl"}:
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>双语 EPUB 审校编辑器</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.15.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.16.0">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
|
||||
<div class="brandBlock">
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.15.0</span></h1>
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.0</span></h1>
|
||||
<p id="sessionMeta">正在载入……</p>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
@@ -509,6 +509,6 @@
|
||||
</aside>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="/static/app.js?v=0.15.0"></script>
|
||||
<script src="/static/app.js?v=0.16.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SERVER_PATH = Path(__file__).with_name("server.py")
|
||||
SPEC = importlib.util.spec_from_file_location("epub_review_editor_server", SERVER_PATH)
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
def write_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
def make_session(
|
||||
self,
|
||||
review_root: Path,
|
||||
session_id: str,
|
||||
epub_path: Path,
|
||||
glossary_path: Path,
|
||||
) -> Path:
|
||||
session_root = review_root / session_id
|
||||
write_json(
|
||||
session_root / "review_state" / "session.json",
|
||||
{
|
||||
"id": session_id,
|
||||
"source_name": epub_path.name,
|
||||
"source_epub": str(epub_path),
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "state.json",
|
||||
{
|
||||
"source_epub": str(epub_path),
|
||||
"edits": {},
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "gpt_config.json",
|
||||
{
|
||||
"base_url": "https://session.example.test/v1",
|
||||
"model": "session-model",
|
||||
"glossary_path": str(glossary_path),
|
||||
"translation_prompt": "session translation prompt",
|
||||
"format_prompt": "session format prompt",
|
||||
"character_prompt": "session character prompt",
|
||||
},
|
||||
)
|
||||
return session_root
|
||||
|
||||
def test_inline_retranslate_uses_request_session_config_and_glossary(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
review_root = root / "review-root"
|
||||
global_glossary = root / "global-glossary.json"
|
||||
session_glossary = root / "session-glossary.json"
|
||||
write_json(global_glossary, {"別語": "全局译名"})
|
||||
write_json(session_glossary, {"ファルシオン": "大砍刀"})
|
||||
write_json(
|
||||
review_root / "gpt_config.json",
|
||||
{
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://global.example.test/v1",
|
||||
"model": "global-model",
|
||||
"glossary_path": str(global_glossary),
|
||||
},
|
||||
)
|
||||
|
||||
epub_path = root / "book.epub"
|
||||
epub_path.write_bytes(b"not a real epub")
|
||||
session_root = self.make_session(review_root, "session-a", epub_path, session_glossary)
|
||||
write_json(
|
||||
session_root / "review_state" / "rows_meta.json",
|
||||
{"parser_version": server.ROWS_PARSER_VERSION},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "rows.json",
|
||||
[
|
||||
{
|
||||
"id": "R00001",
|
||||
"file": "chapter.xhtml",
|
||||
"file_label": "chapter.xhtml",
|
||||
"document_title": "chapter",
|
||||
"ja_p_index": 0,
|
||||
"cn_p_index": 1,
|
||||
"jp_html": "<ruby>ファルシオン<rt>Falchion</rt></ruby>",
|
||||
"jp_text": "ファルシオン",
|
||||
"cn_html": "旧译文",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
captured = {}
|
||||
original_call = server.call_openai_compatible_chat
|
||||
|
||||
def fake_call(config, messages, temperature=0.2, timeout=90):
|
||||
captured["config"] = config
|
||||
captured["messages"] = messages
|
||||
return "<span>候选译文</span>"
|
||||
|
||||
server.call_openai_compatible_chat = fake_call
|
||||
try:
|
||||
app = server.create_app(review_root)
|
||||
response = app.test_client().post(
|
||||
"/api/row/R00001/retranslate?session_id=session-a",
|
||||
json={"instruction": ""},
|
||||
)
|
||||
finally:
|
||||
server.call_openai_compatible_chat = original_call
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
payload = response.get_json()
|
||||
self.assertEqual(payload["model"], "session-model")
|
||||
self.assertEqual(payload["glossary_path"], str(session_glossary))
|
||||
self.assertEqual(payload["glossary_matches"], ["ファルシオン => 大砍刀"])
|
||||
self.assertEqual(captured["config"]["base_url"], "https://session.example.test/v1")
|
||||
self.assertEqual(captured["config"]["translation_prompt"], "session translation prompt")
|
||||
prompt_text = "\n".join(message["content"] for message in captured["messages"])
|
||||
self.assertIn("ファルシオン => 大砍刀", prompt_text)
|
||||
self.assertNotIn("全局译名", prompt_text)
|
||||
|
||||
def test_scoped_gpt_config_saves_secret_globally_and_public_response_hides_it(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
review_root = root / "review-root"
|
||||
glossary = root / "glossary.json"
|
||||
write_json(glossary, {})
|
||||
write_json(review_root / "gpt_config.json", {"glossary_path": str(glossary)})
|
||||
epub_path = root / "book.epub"
|
||||
epub_path.write_bytes(b"not a real epub")
|
||||
self.make_session(review_root, "session-a", epub_path, glossary)
|
||||
|
||||
app = server.create_app(review_root)
|
||||
response = app.test_client().post(
|
||||
"/api/gpt/config?session_id=session-a",
|
||||
json={
|
||||
"api_key": "session-secret",
|
||||
"base_url": "https://session.example.test/v1",
|
||||
"model": "session-model",
|
||||
"glossary_path": str(glossary),
|
||||
"translation_prompt": "session translation prompt",
|
||||
"format_prompt": "session format prompt",
|
||||
"character_prompt": "session character prompt",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
payload = response.get_json()
|
||||
self.assertTrue(payload["configured"])
|
||||
self.assertNotIn("api_key", payload)
|
||||
self.assertEqual(payload["base_url"], "https://session.example.test/v1")
|
||||
self.assertEqual(
|
||||
json.loads((review_root / "gpt_config.json").read_text(encoding="utf-8"))["api_key"],
|
||||
"session-secret",
|
||||
)
|
||||
scoped = json.loads(
|
||||
(review_root / "session-a" / "review_state" / "gpt_config.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("api_key", scoped)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user