75293bd1a4
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled
1184 lines
44 KiB
TypeScript
1184 lines
44 KiB
TypeScript
import clsx from 'clsx';
|
||
import DOMPurify from 'dompurify';
|
||
import {
|
||
Check,
|
||
Download,
|
||
FileText,
|
||
Languages,
|
||
MessageSquarePlus,
|
||
Pin,
|
||
PinOff,
|
||
RefreshCw,
|
||
Save,
|
||
Sparkles,
|
||
X,
|
||
} from 'lucide-react';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||
|
||
import { Overlay } from '@/components/Overlay';
|
||
import { useEnv } from '@/context/EnvContext';
|
||
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
|
||
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 { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
|
||
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
|
||
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 MIN_FLOATING_PANEL_HEIGHT = 0.35;
|
||
const MAX_FLOATING_PANEL_HEIGHT = 0.92;
|
||
const FLOATING_PANEL_MARGIN = 12;
|
||
const DEFAULT_REVIEW_PANEL_WIDTH = 0.32;
|
||
|
||
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 escapeInlineHtml = (value: string) =>
|
||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
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-auto min-h-9 max-w-full whitespace-normal rounded-md px-3 py-2 text-start text-sm leading-snug break-words',
|
||
'[&>svg]:shrink-0',
|
||
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'>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
Base URL
|
||
<input
|
||
className='input input-bordered eink-bordered h-9 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 min-w-0 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 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 panelHeight = useReviewModeStore((state) => state.panelHeight);
|
||
const setPanelVisible = useReviewModeStore((state) => state.setPanelVisible);
|
||
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
|
||
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
|
||
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
|
||
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 getProgress = useReaderStore((state) => state.getProgress);
|
||
const getView = useReaderStore((state) => state.getView);
|
||
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 [previewOpen, setPreviewOpen] = useState(false);
|
||
const isMobile = useIsMobileViewport();
|
||
const panelTopInset = getPanelTopInset({
|
||
isMobile,
|
||
isFullHeightInMobile: true,
|
||
systemUIVisible,
|
||
statusBarHeight,
|
||
safeAreaInsets,
|
||
});
|
||
const isDocked = !isMobile && isPanelPinned;
|
||
const panelRef = useRef<HTMLElement>(null);
|
||
const cnEditorRef = useRef<HTMLTextAreaElement>(null);
|
||
const restoreTimeoutRef = useRef<number | null>(null);
|
||
|
||
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('');
|
||
setPreviewOpen(false);
|
||
}, [selectedRow]);
|
||
|
||
const restoreReadingPositionAfterLayoutChange = useCallback(() => {
|
||
if (!activeBookKey) return;
|
||
const view = getView(activeBookKey);
|
||
const cfi = view?.lastLocation?.cfi || getProgress(activeBookKey)?.location;
|
||
if (!view || !cfi) return;
|
||
if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current);
|
||
restoreTimeoutRef.current = window.setTimeout(() => {
|
||
window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => view.goTo(cfi));
|
||
});
|
||
}, 160);
|
||
}, [activeBookKey, getProgress, getView]);
|
||
|
||
const handleTogglePanelPin = () => {
|
||
togglePanelPin();
|
||
};
|
||
|
||
const floatingPanelEnabled = !isPanelPinned && !isMobile;
|
||
const {
|
||
panelPosition,
|
||
panelPositionRef,
|
||
updatePanelPosition,
|
||
moveToDefaultPosition: moveFloatingPanelToDefault,
|
||
handlePanelDragStart,
|
||
} = useReviewPanelDrag({
|
||
enabled: floatingPanelEnabled,
|
||
panelRef,
|
||
margin: FLOATING_PANEL_MARGIN,
|
||
defaultWidthRatio: DEFAULT_REVIEW_PANEL_WIDTH,
|
||
topInset: panelTopInset,
|
||
});
|
||
|
||
const {
|
||
handleWidthPointerDown: handleFloatingWidthPointerDown,
|
||
handleWidthKeyDown: handleFloatingWidthKeyDown,
|
||
handleHeightPointerDown: handleFloatingHeightPointerDown,
|
||
handleHeightKeyDown: handleFloatingHeightKeyDown,
|
||
} = useReviewPanelFloatingResize({
|
||
enabled: floatingPanelEnabled,
|
||
panelRef,
|
||
panelPositionRef,
|
||
panelWidth,
|
||
panelHeight,
|
||
minWidth: MIN_REVIEW_PANEL_WIDTH,
|
||
maxWidth: MAX_REVIEW_PANEL_WIDTH,
|
||
minHeight: MIN_FLOATING_PANEL_HEIGHT,
|
||
maxHeight: MAX_FLOATING_PANEL_HEIGHT,
|
||
setPanelWidth,
|
||
setPanelHeight,
|
||
updatePanelPosition,
|
||
});
|
||
|
||
const {
|
||
handleResizeStart: handleDockedResizeStart,
|
||
handleResizeKeyDown: handleDockedResizeKeyDown,
|
||
} = usePanelResize({
|
||
side: 'end',
|
||
minWidth: MIN_REVIEW_PANEL_WIDTH,
|
||
maxWidth: MAX_REVIEW_PANEL_WIDTH,
|
||
getWidth: () => panelWidth,
|
||
onResize: setPanelWidth,
|
||
});
|
||
|
||
const handleResizeKeyDown = (event: React.KeyboardEvent) => {
|
||
if (floatingPanelEnabled) {
|
||
handleFloatingWidthKeyDown(event);
|
||
} else {
|
||
handleDockedResizeKeyDown(event);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
moveFloatingPanelToDefault();
|
||
}, [moveFloatingPanelToDefault]);
|
||
|
||
useEffect(() => {
|
||
if (!isPanelVisible || isMobile) return;
|
||
restoreReadingPositionAfterLayoutChange();
|
||
}, [isMobile, isPanelPinned, isPanelVisible, restoreReadingPositionAfterLayoutChange]);
|
||
|
||
useEffect(() => {
|
||
if (!isPanelVisible || isMobile || !isPanelPinned) return;
|
||
restoreReadingPositionAfterLayoutChange();
|
||
}, [
|
||
isMobile,
|
||
isPanelPinned,
|
||
isPanelVisible,
|
||
panelWidth,
|
||
restoreReadingPositionAfterLayoutChange,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current);
|
||
};
|
||
}, []);
|
||
|
||
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 insertIntoCurrentHtml = (
|
||
buildInsert: (selectedText: string) => {
|
||
html: string;
|
||
selectStart?: number;
|
||
selectEnd?: number;
|
||
} | null,
|
||
) => {
|
||
const editor = cnEditorRef.current;
|
||
const source = edit.current_html;
|
||
const selectionStart = editor?.selectionStart ?? source.length;
|
||
const selectionEnd = editor?.selectionEnd ?? source.length;
|
||
const selectedText = source.slice(selectionStart, selectionEnd);
|
||
const insert = buildInsert(selectedText);
|
||
if (insert === null) return;
|
||
const nextHtml = `${source.slice(0, selectionStart)}${insert.html}${source.slice(selectionEnd)}`;
|
||
setEdit((current) => ({ ...current, current_html: nextHtml }));
|
||
setPreviewOpen(true);
|
||
requestAnimationFrame(() => {
|
||
editor?.focus();
|
||
const selectStart = selectionStart + (insert.selectStart ?? insert.html.length);
|
||
const selectEnd = selectionStart + (insert.selectEnd ?? insert.html.length);
|
||
editor?.setSelectionRange(selectStart, selectEnd);
|
||
});
|
||
};
|
||
|
||
const insertRuby = () => {
|
||
insertIntoCurrentHtml((selectedText) => {
|
||
const baseText = selectedText || '文字';
|
||
const rubyText = '注音';
|
||
const baseHtml = escapeInlineHtml(baseText);
|
||
const prefix = `<ruby><rb>${baseHtml}</rb><rt>`;
|
||
const html = `${prefix}${rubyText}</rt></ruby>`;
|
||
if (selectedText) {
|
||
return {
|
||
html,
|
||
selectStart: prefix.length,
|
||
selectEnd: prefix.length + rubyText.length,
|
||
};
|
||
}
|
||
const baseStart = '<ruby><rb>'.length;
|
||
return {
|
||
html,
|
||
selectStart: baseStart,
|
||
selectEnd: baseStart + baseText.length,
|
||
};
|
||
});
|
||
};
|
||
|
||
const insertAnnotation = () => {
|
||
insertIntoCurrentHtml((selectedText) => {
|
||
const noteText = '注释';
|
||
const baseHtml = selectedText ? escapeInlineHtml(selectedText) : '';
|
||
const prefix = `${baseHtml}<span class="review-annotation">(注:`;
|
||
const html = `${prefix}${noteText})</span>`;
|
||
return {
|
||
html,
|
||
selectStart: prefix.length,
|
||
selectEnd: prefix.length + noteText.length,
|
||
};
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{isMobile && (
|
||
<Overlay
|
||
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
||
onDismiss={() => setPanelVisible(false)}
|
||
/>
|
||
)}
|
||
<aside
|
||
ref={panelRef}
|
||
className={clsx(
|
||
'review-panel-container flex min-w-72 select-none flex-col',
|
||
isDocked && 'shrink-0',
|
||
'font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
|
||
(isDocked || isMobile) && 'full-height',
|
||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||
isDocked ? 'z-20' : 'z-[45] shadow-2xl',
|
||
isDocked && 'border-base-300 border-s',
|
||
!isDocked && 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}%`,
|
||
height: !isMobile && !isPanelPinned ? panelHeight : undefined,
|
||
maxHeight:
|
||
!isMobile && !isPanelPinned
|
||
? `calc(100vh - ${FLOATING_PANEL_MARGIN * 2}px)`
|
||
: undefined,
|
||
position: isMobile ? 'fixed' : isPanelPinned ? 'relative' : 'absolute',
|
||
left: !isMobile && !isPanelPinned ? `${panelPosition.x}px` : undefined,
|
||
top: !isMobile && !isPanelPinned ? `${panelPosition.y}px` : undefined,
|
||
right: isMobile ? 0 : undefined,
|
||
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)}
|
||
onPointerDown={floatingPanelEnabled ? handleFloatingWidthPointerDown : undefined}
|
||
onMouseDown={floatingPanelEnabled ? undefined : handleDockedResizeStart}
|
||
onTouchStart={floatingPanelEnabled ? undefined : handleDockedResizeStart}
|
||
onKeyDown={handleResizeKeyDown}
|
||
/>
|
||
{!isMobile && !isPanelPinned ? (
|
||
<div
|
||
className='drag-bar absolute inset-x-0 -bottom-2 h-0.5 cursor-row-resize bg-transparent p-2'
|
||
role='slider'
|
||
tabIndex={0}
|
||
aria-label='调整审校面板高度'
|
||
aria-orientation='vertical'
|
||
aria-valuenow={parseFloat(panelHeight)}
|
||
onPointerDown={handleFloatingHeightPointerDown}
|
||
onKeyDown={handleFloatingHeightKeyDown}
|
||
/>
|
||
) : null}
|
||
<header
|
||
className={clsx(
|
||
'border-base-300 flex min-h-12 shrink-0 items-center gap-2 border-b px-3 py-2',
|
||
!isPanelPinned && !isMobile && 'cursor-move',
|
||
)}
|
||
onPointerDown={handlePanelDragStart}
|
||
>
|
||
<div className='min-w-0 flex-1'>
|
||
<h2 className='text-sm font-semibold break-words'>审校模式</h2>
|
||
<p className='text-base-content/60 text-xs break-words'>
|
||
{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 shrink-0 rounded-full p-0'
|
||
title={isPanelPinned ? '取消固定:浮动面板不挤开正文' : '固定面板:挤开正文'}
|
||
onClick={handleTogglePanelPin}
|
||
>
|
||
{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 shrink-0 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}
|
||
|
||
{!selectedRow ? (
|
||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
|
||
{rows.length
|
||
? '请选择一段开始审校。'
|
||
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
|
||
<span className='min-w-0 break-words'>
|
||
{selectedRow.id} · {rowTitle(selectedRow)}
|
||
</span>
|
||
<span className='shrink-0 break-words'>
|
||
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className='prose prose-sm max-w-none select-text leading-8 break-words'
|
||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
|
||
/>
|
||
</section>
|
||
|
||
<section className='grid gap-3'>
|
||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
||
<div className='flex flex-wrap justify-end gap-2'>
|
||
<PanelButton
|
||
onClick={insertRuby}
|
||
disabled={!canUseRowActions}
|
||
icon={<Languages className='h-4 w-4' />}
|
||
>
|
||
注音
|
||
</PanelButton>
|
||
<PanelButton
|
||
onClick={insertAnnotation}
|
||
disabled={!canUseRowActions}
|
||
icon={<MessageSquarePlus className='h-4 w-4' />}
|
||
>
|
||
注释
|
||
</PanelButton>
|
||
<PanelButton
|
||
onClick={saveCurrentRow}
|
||
disabled={!canUseRowActions || saving}
|
||
icon={<Save className='h-4 w-4' />}
|
||
variant='primary'
|
||
>
|
||
{saving ? '保存中' : '保存'}
|
||
</PanelButton>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
ref={cnEditorRef}
|
||
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
|
||
value={edit.current_html}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, current_html: event.target.value }))
|
||
}
|
||
/>
|
||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||
<button
|
||
type='button'
|
||
className='flex w-full items-center justify-between gap-2 text-start'
|
||
onClick={() => setPreviewOpen((value) => !value)}
|
||
>
|
||
<span className='text-base-content/60 text-xs'>译文预览</span>
|
||
<span className='text-base-content/60 text-xs'>
|
||
{previewOpen ? '收起' : '展开'}
|
||
</span>
|
||
</button>
|
||
{previewOpen ? (
|
||
<div
|
||
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
|
||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||
<div className='flex items-start justify-between gap-3'>
|
||
<div className='min-w-0'>
|
||
<h4 className='text-sm font-semibold'>问题记录</h4>
|
||
<p className='text-base-content/60 mt-1 text-xs'>
|
||
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
|
||
</p>
|
||
</div>
|
||
<label className='flex shrink-0 items-center gap-2 text-xs'>
|
||
<input
|
||
type='checkbox'
|
||
className='checkbox checkbox-sm'
|
||
checked={edit.marked}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, marked: event.target.checked }))
|
||
}
|
||
/>
|
||
纳入反馈
|
||
</label>
|
||
</div>
|
||
<div className='grid gap-2 sm:grid-cols-2'>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
问题类型
|
||
<select
|
||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||
value={edit.issue_type}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({
|
||
...current,
|
||
issue_type: event.target.value,
|
||
}))
|
||
}
|
||
>
|
||
<option value=''>未选择</option>
|
||
<option value='误译'>误译</option>
|
||
<option value='漏译'>漏译</option>
|
||
<option value='术语不一致'>术语不一致</option>
|
||
<option value='人名/地名问题'>人名/地名问题</option>
|
||
<option value='翻译腔'>翻译腔</option>
|
||
<option value='角色口吻不对'>角色口吻不对</option>
|
||
<option value='语序不顺'>语序不顺</option>
|
||
<option value='标点/格式'>标点/格式</option>
|
||
<option value='其他'>其他</option>
|
||
</select>
|
||
</label>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
严重程度
|
||
<select
|
||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||
value={edit.severity}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, severity: event.target.value }))
|
||
}
|
||
>
|
||
<option value=''>未选择</option>
|
||
<option value='轻微'>轻微</option>
|
||
<option value='中等'>中等</option>
|
||
<option value='严重'>严重</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
标签
|
||
<input
|
||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||
value={edit.tags}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, tags: event.target.value }))
|
||
}
|
||
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
|
||
/>
|
||
</label>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
本段备注
|
||
<textarea
|
||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||
value={edit.comment}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, comment: event.target.value }))
|
||
}
|
||
placeholder='只记录当前段哪里有问题、为什么这样改'
|
||
/>
|
||
</label>
|
||
<label className='grid gap-1 text-xs font-medium'>
|
||
长期规则建议
|
||
<textarea
|
||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||
value={edit.learn_note}
|
||
onChange={(event) =>
|
||
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
||
}
|
||
placeholder='只写后续段落也应遵守的规则或口吻偏好'
|
||
/>
|
||
</label>
|
||
</section>
|
||
</section>
|
||
|
||
<section className='border-base-300 grid gap-3 border-t pt-3'>
|
||
<h3 className='text-sm font-semibold'>API 重翻</h3>
|
||
<textarea
|
||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||
value={retranslateInstruction}
|
||
onChange={(event) => setRetranslateInstruction(event.target.value)}
|
||
placeholder='额外要求,可留空'
|
||
/>
|
||
<PanelButton
|
||
onClick={retranslateCurrentRow}
|
||
disabled={!canUseRowActions || retranslating}
|
||
icon={<Sparkles className='h-4 w-4' />}
|
||
>
|
||
{retranslating ? '重翻中' : '重翻本段'}
|
||
</PanelButton>
|
||
{candidateHtml ? (
|
||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||
<div
|
||
className='prose prose-sm max-w-none select-text leading-7 break-words'
|
||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
|
||
/>
|
||
<div className='mt-3'>
|
||
<PanelButton
|
||
onClick={() =>
|
||
setEdit((current) => ({ ...current, current_html: candidateHtml }))
|
||
}
|
||
icon={<Check className='h-4 w-4' />}
|
||
variant='primary'
|
||
>
|
||
应用候选到编辑框
|
||
</PanelButton>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
<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 }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ReviewPanel;
|