fix: streamline document parsing settings
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
InstalledDocumentOcrModel,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot
|
||||
} from '../../shared/document-parsing-contracts'
|
||||
@@ -16,12 +17,8 @@ import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection
|
||||
const settings: DocumentParsingSettings = {
|
||||
chatWorkflow: 'auto',
|
||||
knowledgeWorkflow: 'complete-index',
|
||||
pdfOcrMode: 'auto',
|
||||
ocrProvider: 'local',
|
||||
localOcrEnabled: true,
|
||||
localOcrModelId: 'pp-ocrv6-tiny',
|
||||
maximumPages: 100,
|
||||
ocrConcurrency: 1,
|
||||
pageTimeoutSeconds: 60
|
||||
}
|
||||
|
||||
@@ -139,35 +136,36 @@ const test = vi.fn(async () => ({
|
||||
preview: '扫描件识别正文',
|
||||
warnings: []
|
||||
}))
|
||||
const installOcrModel = vi.fn(async () => ({
|
||||
...snapshot,
|
||||
status: {
|
||||
...snapshot.status,
|
||||
localOcr: {
|
||||
...snapshot.status.localOcr,
|
||||
available: true,
|
||||
verified: true,
|
||||
detail: '模型已安装并校验'
|
||||
}
|
||||
},
|
||||
ocrModels: {
|
||||
...snapshot.ocrModels,
|
||||
installed: [
|
||||
{
|
||||
id: 'pp-ocrv6-tiny' as const,
|
||||
displayName: 'PP-OCRv6 Tiny',
|
||||
source: 'download' as const,
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
files: modelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
}))
|
||||
const installOcrModel =
|
||||
vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({
|
||||
...snapshot,
|
||||
status: {
|
||||
...snapshot.status,
|
||||
localOcr: {
|
||||
...snapshot.status.localOcr,
|
||||
available: true,
|
||||
verified: true,
|
||||
detail: '模型已安装并校验'
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
},
|
||||
ocrModels: {
|
||||
...snapshot.ocrModels,
|
||||
installed: [
|
||||
{
|
||||
id: 'pp-ocrv6-tiny',
|
||||
displayName: 'PP-OCRv6 Tiny',
|
||||
source: 'download',
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
files: modelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
const importOcrModelArchive = vi.fn(async () => snapshot)
|
||||
const exportOcrModelArchive = vi.fn(async () => snapshot)
|
||||
const openOcrModelRepository = vi.fn(async () => undefined)
|
||||
@@ -212,17 +210,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(screen.getByText('质量:基础')).toBeInTheDocument()
|
||||
expect(screen.getByText('速度:快')).toBeInTheDocument()
|
||||
expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /启用本地 OCR/u })
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '本地模型' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '远程服务(即将支持)'
|
||||
})
|
||||
).toBeDisabled()
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('模型详情与手动导入')
|
||||
@@ -237,12 +225,15 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
expect(openOcrModelRepository).toHaveBeenCalledWith('pp-ocrv6-tiny')
|
||||
|
||||
fireEvent.change(screen.getByLabelText('聊天附件'), {
|
||||
fireEvent.change(screen.getByLabelText('聊天与成果文件'), {
|
||||
target: { value: 'fast-text' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存设置' })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '测试聊天与成果模式'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
@@ -278,6 +269,59 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads and selects an uninstalled model in one action', async () => {
|
||||
const installedMedium = {
|
||||
...snapshot,
|
||||
settings: {
|
||||
...snapshot.settings,
|
||||
localOcrModelId: 'pp-ocrv6-medium'
|
||||
}
|
||||
}
|
||||
installOcrModel.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
ocrModels: {
|
||||
...snapshot.ocrModels,
|
||||
installed: [
|
||||
...snapshot.ocrModels.installed,
|
||||
{
|
||||
id: 'pp-ocrv6-medium',
|
||||
displayName: 'PP-OCRv6 Medium',
|
||||
source: 'download',
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
files: thirdModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
}))
|
||||
} satisfies InstalledDocumentOcrModel
|
||||
]
|
||||
}
|
||||
})
|
||||
update.mockResolvedValueOnce(installedMedium)
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('当前 OCR 模型'), {
|
||||
target: { value: 'pp-ocrv6-medium' }
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存设置' })
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '下载 PP-OCRv6 Medium'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
localOcrModelId: 'pp-ocrv6-medium'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('imports and exports verified OCR model ZIP archives', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
@@ -370,7 +414,9 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
await screen.findByText('PP-OCRv6 Tiny')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '测试解析' })
|
||||
screen.getByRole('button', {
|
||||
name: '测试聊天与成果模式'
|
||||
})
|
||||
)
|
||||
|
||||
expect(
|
||||
@@ -378,6 +424,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
name: '解析测试结果'
|
||||
})
|
||||
).toHaveTextContent('扫描件识别正文')
|
||||
expect(test).toHaveBeenCalledOnce()
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,11 +25,11 @@ import type {
|
||||
DocumentOcrModelCatalogEntry,
|
||||
DocumentOcrModelOperation,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot
|
||||
DocumentParsingSnapshot,
|
||||
DocumentParsingTestPurpose
|
||||
} from '../../shared/document-parsing-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -211,7 +211,8 @@ export function DocumentParsingSettingsSection({
|
||||
const [draft, setDraft] = useState<DocumentParsingSettings>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testingPurpose, setTestingPurpose] =
|
||||
useState<DocumentParsingTestPurpose>()
|
||||
const [busyModelId, setBusyModelId] = useState<string>()
|
||||
const [confirmingRemove, setConfirmingRemove] = useState<string>()
|
||||
const [diagnostic, setDiagnostic] =
|
||||
@@ -283,9 +284,7 @@ export function DocumentParsingSettingsSection({
|
||||
)
|
||||
}
|
||||
|
||||
const save = async (
|
||||
notify = true
|
||||
): Promise<DocumentParsingSnapshot | undefined> => {
|
||||
const save = async (): Promise<DocumentParsingSnapshot | undefined> => {
|
||||
const api = window.goodbuddy.documentParsing
|
||||
if (!api || !draft) {
|
||||
return undefined
|
||||
@@ -296,13 +295,11 @@ export function DocumentParsingSettingsSection({
|
||||
const next = await api.update(draft)
|
||||
setSnapshot(next)
|
||||
setDraft(next.settings)
|
||||
if (notify) {
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t('notifications.documentParsingSaved'),
|
||||
dedupeKey: 'document-parsing-saved'
|
||||
})
|
||||
}
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t('notifications.documentParsingSaved'),
|
||||
dedupeKey: 'document-parsing-saved'
|
||||
})
|
||||
return next
|
||||
} catch (reason) {
|
||||
setError(
|
||||
@@ -365,19 +362,18 @@ export function DocumentParsingSettingsSection({
|
||||
)
|
||||
}
|
||||
|
||||
const testParsing = async (): Promise<void> => {
|
||||
const testParsing = async (
|
||||
purpose: DocumentParsingTestPurpose
|
||||
): Promise<void> => {
|
||||
const api = window.goodbuddy.documentParsing
|
||||
if (!api) {
|
||||
setError(t('errors.documentParsingUnavailable'))
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
setTestingPurpose(purpose)
|
||||
setError(undefined)
|
||||
try {
|
||||
if (!(await save(false))) {
|
||||
return
|
||||
}
|
||||
const result = await api.test()
|
||||
const result = await api.test(purpose)
|
||||
if (result) {
|
||||
setDiagnostic(result)
|
||||
onNotify?.({
|
||||
@@ -391,7 +387,7 @@ export function DocumentParsingSettingsSection({
|
||||
errorMessage(reason, t('errors.testDocumentParsing'))
|
||||
)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
setTestingPurpose(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +411,10 @@ export function DocumentParsingSettingsSection({
|
||||
const model = snapshot.ocrModels.catalog.find(
|
||||
(entry) => entry.id === draft.localOcrModelId
|
||||
)
|
||||
const selectedModelInCatalog = model !== undefined
|
||||
const currentModelInCatalog = snapshot.ocrModels.catalog.some(
|
||||
(entry) => entry.id === snapshot.settings.localOcrModelId
|
||||
)
|
||||
const installedModel = snapshot.ocrModels.installed.find(
|
||||
(entry) => entry.id === draft.localOcrModelId
|
||||
)
|
||||
@@ -426,38 +426,41 @@ export function DocumentParsingSettingsSection({
|
||||
: undefined
|
||||
const pendingModelSelection =
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const settingsDirty =
|
||||
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
|
||||
const selectedModelReady = installedModel !== undefined
|
||||
const invalidPendingModel =
|
||||
pendingModelSelection && !selectedModelReady
|
||||
const testing = testingPurpose !== undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCategoryHeader
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={saving || testing}
|
||||
onClick={() => void testParsing()}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{testing
|
||||
? t('actions.testingParsing')
|
||||
: t('actions.testParsing')}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving || testing}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{saving
|
||||
? t('actions.saving')
|
||||
: t('actions.saveSettings')}
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={
|
||||
saving || testing || !settingsDirty || invalidPendingModel
|
||||
}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{saving
|
||||
? t('actions.saving')
|
||||
: t('actions.saveSettings')}
|
||||
</button>
|
||||
}
|
||||
category="document-parsing"
|
||||
error={error}
|
||||
/>
|
||||
{settingsDirty && (
|
||||
<p
|
||||
className="settings-notice"
|
||||
id="document-parsing-unsaved-notice"
|
||||
>
|
||||
{t('documentParsing.workflows.unsavedNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-labelledby="document-parsing-status-title"
|
||||
@@ -483,9 +486,13 @@ export function DocumentParsingSettingsSection({
|
||||
detail={t(
|
||||
snapshot.status.localOcr.available
|
||||
? 'documentParsing.status.ocrReady'
|
||||
: 'documentParsing.status.ocrUnavailable'
|
||||
: currentModelInCatalog
|
||||
? 'documentParsing.status.ocrUnavailable'
|
||||
: 'documentParsing.ocr.selectedModelUnavailable'
|
||||
)}
|
||||
label={t('documentParsing.status.localOcr')}
|
||||
label={t('documentParsing.status.localOcrModel', {
|
||||
name: snapshot.status.localOcr.displayName
|
||||
})}
|
||||
/>
|
||||
<StatusRow
|
||||
available={snapshot.status.conversionAvailable}
|
||||
@@ -514,10 +521,13 @@ export function DocumentParsingSettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-parsing-grid">
|
||||
<label className="field">
|
||||
<span>{t('documentParsing.workflows.chat')}</span>
|
||||
<div className="field">
|
||||
<label htmlFor="document-parsing-chat-workflow">
|
||||
{t('documentParsing.workflows.chat')}
|
||||
</label>
|
||||
<select
|
||||
aria-label={t('documentParsing.workflows.chat')}
|
||||
id="document-parsing-chat-workflow"
|
||||
onChange={(event) =>
|
||||
updateDraft(
|
||||
'chatWorkflow',
|
||||
@@ -540,13 +550,36 @@ export function DocumentParsingSettingsSection({
|
||||
</option>
|
||||
</select>
|
||||
<small>
|
||||
{t('documentParsing.workflows.chatDescription')}
|
||||
{t(
|
||||
`documentParsing.workflows.chatDescriptions.${draft.chatWorkflow}`
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('documentParsing.workflows.knowledge')}</span>
|
||||
<button
|
||||
aria-describedby={
|
||||
settingsDirty
|
||||
? 'document-parsing-unsaved-notice'
|
||||
: undefined
|
||||
}
|
||||
className="secondary-button document-parsing-workflow-test"
|
||||
disabled={saving || testing || settingsDirty}
|
||||
onClick={() =>
|
||||
void testParsing('chat-attachment')
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{testingPurpose === 'chat-attachment'
|
||||
? t('actions.testingParsing')
|
||||
: t('documentParsing.workflows.testChat')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="document-parsing-knowledge-workflow">
|
||||
{t('documentParsing.workflows.knowledge')}
|
||||
</label>
|
||||
<select
|
||||
aria-label={t('documentParsing.workflows.knowledge')}
|
||||
id="document-parsing-knowledge-workflow"
|
||||
onChange={(event) =>
|
||||
updateDraft(
|
||||
'knowledgeWorkflow',
|
||||
@@ -573,9 +606,29 @@ export function DocumentParsingSettingsSection({
|
||||
</option>
|
||||
</select>
|
||||
<small>
|
||||
{t('documentParsing.workflows.knowledgeDescription')}
|
||||
{t(
|
||||
`documentParsing.workflows.knowledgeDescriptions.${draft.knowledgeWorkflow}`
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
<button
|
||||
aria-describedby={
|
||||
settingsDirty
|
||||
? 'document-parsing-unsaved-notice'
|
||||
: undefined
|
||||
}
|
||||
className="secondary-button document-parsing-workflow-test"
|
||||
disabled={saving || testing || settingsDirty}
|
||||
onClick={() =>
|
||||
void testParsing('knowledge-index')
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{testingPurpose === 'knowledge-index'
|
||||
? t('actions.testingParsing')
|
||||
: t('documentParsing.workflows.testKnowledge')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -604,40 +657,6 @@ export function DocumentParsingSettingsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="document-ocr-provider">
|
||||
<div>
|
||||
<strong>{t('documentParsing.ocr.provider.title')}</strong>
|
||||
<small>
|
||||
{t('documentParsing.ocr.provider.description')}
|
||||
</small>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('documentParsing.ocr.provider.title')}
|
||||
onChange={(value) => {
|
||||
if (value === 'local') {
|
||||
updateDraft('ocrProvider', value)
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: 'local',
|
||||
label: t('documentParsing.ocr.provider.local')
|
||||
},
|
||||
{
|
||||
value: 'remote',
|
||||
label: t('documentParsing.ocr.provider.remote'),
|
||||
disabled: true
|
||||
}
|
||||
]}
|
||||
value={draft.ocrProvider}
|
||||
/>
|
||||
<small>
|
||||
{t('documentParsing.ocr.provider.remoteDescription')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{draft.ocrProvider === 'local' && (
|
||||
<>
|
||||
<label className="field document-ocr-model-selector">
|
||||
<span>{t('documentParsing.ocr.modelSelector')}</span>
|
||||
<select
|
||||
@@ -647,6 +666,12 @@ export function DocumentParsingSettingsSection({
|
||||
}
|
||||
value={draft.localOcrModelId}
|
||||
>
|
||||
{!selectedModelInCatalog && (
|
||||
<option value={draft.localOcrModelId}>
|
||||
{draft.localOcrModelId} ·{' '}
|
||||
{t('documentParsing.ocr.unavailableOption')}
|
||||
</option>
|
||||
)}
|
||||
{snapshot.ocrModels.catalog.map((entry) => {
|
||||
const installed = snapshot.ocrModels.installed.some(
|
||||
(candidate) => candidate.id === entry.id
|
||||
@@ -662,7 +687,9 @@ export function DocumentParsingSettingsSection({
|
||||
})}
|
||||
</select>
|
||||
<small>
|
||||
{pendingModelSelection
|
||||
{invalidPendingModel
|
||||
? t('documentParsing.ocr.installBeforeSelecting')
|
||||
: pendingModelSelection
|
||||
? t('documentParsing.ocr.pendingSelection')
|
||||
: t('documentParsing.ocr.modelSelectorDescription')}
|
||||
</small>
|
||||
@@ -835,11 +862,20 @@ export function DocumentParsingSettingsSection({
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.documentParsing!
|
||||
.installOcrModel(model.id),
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(model.id)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
'documentParsing.ocr.notifications.installed',
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: model.displayName }
|
||||
)
|
||||
)
|
||||
@@ -847,7 +883,9 @@ export function DocumentParsingSettingsSection({
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t('documentParsing.ocr.download')}
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
@@ -859,11 +897,20 @@ export function DocumentParsingSettingsSection({
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.documentParsing!
|
||||
.importOcrModelArchive(model.id),
|
||||
async () => {
|
||||
const imported =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.importOcrModelArchive(model.id)
|
||||
if (!imported || !pendingModelSelection) {
|
||||
return imported
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
'documentParsing.ocr.notifications.importedZip',
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.importedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.importedZip',
|
||||
{ name: model.displayName }
|
||||
)
|
||||
)
|
||||
@@ -904,55 +951,17 @@ export function DocumentParsingSettingsSection({
|
||||
</article>
|
||||
) : (
|
||||
<p className="settings-warning">
|
||||
{t('documentParsing.ocr.catalogUnavailable')}
|
||||
{t(
|
||||
snapshot.ocrModels.catalog.length === 0
|
||||
? 'documentParsing.ocr.catalogUnavailable'
|
||||
: 'documentParsing.ocr.selectedModelUnavailable'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="document-ocr-settings__options">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={draft.localOcrEnabled}
|
||||
onChange={(event) =>
|
||||
updateDraft('localOcrEnabled', event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="field">
|
||||
<strong>{t('documentParsing.ocr.enabled')}</strong>
|
||||
<small>
|
||||
{t('documentParsing.ocr.enabledDescription')}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('documentParsing.ocr.mode')}</span>
|
||||
<select
|
||||
aria-label={t('documentParsing.ocr.mode')}
|
||||
disabled={!draft.localOcrEnabled}
|
||||
onChange={(event) =>
|
||||
updateDraft(
|
||||
'pdfOcrMode',
|
||||
event.target
|
||||
.value as DocumentParsingSettings['pdfOcrMode']
|
||||
)
|
||||
}
|
||||
value={draft.pdfOcrMode}
|
||||
>
|
||||
<option value="auto">
|
||||
{t('documentParsing.ocr.modes.auto')}
|
||||
</option>
|
||||
<option value="always">
|
||||
{t('documentParsing.ocr.modes.always')}
|
||||
</option>
|
||||
<option value="disabled">
|
||||
{t('documentParsing.ocr.modes.disabled')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="settings-notice">
|
||||
{t('documentParsing.ocr.privacyNotice')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<details className="settings-section">
|
||||
@@ -973,21 +982,6 @@ export function DocumentParsingSettingsSection({
|
||||
value={draft.maximumPages}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('documentParsing.advanced.concurrency')}</span>
|
||||
<input
|
||||
max={4}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
updateDraft(
|
||||
'ocrConcurrency',
|
||||
Number(event.target.value)
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
value={draft.ocrConcurrency}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('documentParsing.advanced.timeout')}</span>
|
||||
<input
|
||||
@@ -1004,7 +998,7 @@ export function DocumentParsingSettingsSection({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<small>{t('documentParsing.advanced.concurrencyHint')}</small>
|
||||
<small>{t('documentParsing.advanced.description')}</small>
|
||||
</details>
|
||||
|
||||
{diagnostic && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
|
||||
type WorkerOutput =
|
||||
| { type: 'ready' }
|
||||
| { type: 'progress'; requestId: string; pageNumber?: number }
|
||||
| { type: 'result'; result: DocumentOcrResult }
|
||||
| { type: 'error'; requestId?: string; error: string }
|
||||
|
||||
@@ -13,6 +14,7 @@ type PendingWorkerRequest = {
|
||||
resolve: (result: DocumentOcrResult) => void
|
||||
reject: (error: Error) => void
|
||||
timer: number
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
@@ -46,6 +48,18 @@ function terminateWorker(error: Error): void {
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
function armPageTimeout(
|
||||
requestId: string,
|
||||
request: PendingWorkerRequest
|
||||
): void {
|
||||
window.clearTimeout(request.timer)
|
||||
request.timer = window.setTimeout(() => {
|
||||
pending.delete(requestId)
|
||||
terminateWorker(new Error('单页 OCR 解析超时'))
|
||||
request.reject(new Error('单页 OCR 解析超时'))
|
||||
}, request.timeoutMs)
|
||||
}
|
||||
|
||||
async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
if (worker && workerReady && workerModelId === modelId) {
|
||||
await workerReady
|
||||
@@ -74,6 +88,13 @@ async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
resolveWorkerReady?.()
|
||||
return
|
||||
}
|
||||
if (output.type === 'progress') {
|
||||
const request = pending.get(output.requestId)
|
||||
if (request) {
|
||||
armPageTimeout(output.requestId, request)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (output.type === 'error' && !output.requestId) {
|
||||
rejectWorkerReady?.(new Error(output.error))
|
||||
return
|
||||
@@ -134,21 +155,16 @@ async function recognize(
|
||||
if (cancelledRequestIds.has(request.requestId)) {
|
||||
throw new Error('本地 OCR 解析已取消')
|
||||
}
|
||||
const pageCount = request.pageNumbers?.length ?? request.maximumPages
|
||||
const timeoutMs = Math.min(
|
||||
10 * 60 * 1_000,
|
||||
Math.max(
|
||||
request.pageTimeoutSeconds * 1_000,
|
||||
request.pageTimeoutSeconds * pageCount * 1_000
|
||||
)
|
||||
)
|
||||
const timeoutMs = request.pageTimeoutSeconds * 1_000
|
||||
return new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
pending.delete(request.requestId)
|
||||
terminateWorker(new Error('本地 OCR 解析超时'))
|
||||
reject(new Error('本地 OCR 解析超时'))
|
||||
}, timeoutMs)
|
||||
pending.set(request.requestId, { resolve, reject, timer })
|
||||
const workerRequest = {
|
||||
resolve,
|
||||
reject,
|
||||
timer: 0,
|
||||
timeoutMs
|
||||
}
|
||||
pending.set(request.requestId, workerRequest)
|
||||
armPageTimeout(request.requestId, workerRequest)
|
||||
activeWorker.postMessage(
|
||||
{ type: 'recognize', request },
|
||||
[request.data]
|
||||
|
||||
@@ -26,6 +26,7 @@ type WorkerInput = InitializeMessage | RecognizeMessage
|
||||
|
||||
type WorkerOutput =
|
||||
| { type: 'ready' }
|
||||
| { type: 'progress'; requestId: string; pageNumber?: number }
|
||||
| { type: 'result'; result: DocumentOcrResult }
|
||||
| { type: 'error'; requestId?: string; error: string }
|
||||
|
||||
@@ -141,6 +142,10 @@ async function renderPdfPage(
|
||||
async function recognizePdf(
|
||||
request: DocumentOcrRequest
|
||||
): Promise<DocumentOcrResult> {
|
||||
worker.postMessage({
|
||||
type: 'progress',
|
||||
requestId: request.requestId
|
||||
} satisfies WorkerOutput)
|
||||
const pdfjs = await import('pdfjs-dist')
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
||||
const loadingTask = pdfjs.getDocument(
|
||||
@@ -150,14 +155,21 @@ async function recognizePdf(
|
||||
const selectedPages = new Set(
|
||||
request.pageNumbers ??
|
||||
Array.from(
|
||||
{ length: Math.min(document.numPages, request.maximumPages) },
|
||||
{ length: document.numPages },
|
||||
(_, index) => index + 1
|
||||
)
|
||||
)
|
||||
if (document.numPages > request.maximumPages) {
|
||||
const invalidPage = [...selectedPages].find(
|
||||
(pageNumber) => pageNumber > document.numPages
|
||||
)
|
||||
if (invalidPage !== undefined) {
|
||||
await loadingTask.destroy()
|
||||
throw new Error(`PDF 不包含第 ${invalidPage} 页`)
|
||||
}
|
||||
if (selectedPages.size > request.maximumPages) {
|
||||
await loadingTask.destroy()
|
||||
throw new Error(
|
||||
`PDF 共 ${document.numPages} 页,超过 ${request.maximumPages} 页限制`
|
||||
`PDF 有 ${selectedPages.size} 页需要 OCR,超过 ${request.maximumPages} 页限制`
|
||||
)
|
||||
}
|
||||
const sections: DocumentOcrResult['sections'] = []
|
||||
@@ -171,6 +183,11 @@ async function recognizePdf(
|
||||
if (!selectedPages.has(pageNumber)) {
|
||||
continue
|
||||
}
|
||||
worker.postMessage({
|
||||
type: 'progress',
|
||||
requestId: request.requestId,
|
||||
pageNumber
|
||||
} satisfies WorkerOutput)
|
||||
const page = await document.getPage(pageNumber)
|
||||
try {
|
||||
const section = await recognizeImage(
|
||||
@@ -201,6 +218,11 @@ async function recognize(request: DocumentOcrRequest): Promise<DocumentOcrResult
|
||||
if (request.mimeType === 'application/pdf') {
|
||||
return recognizePdf(request)
|
||||
}
|
||||
worker.postMessage({
|
||||
type: 'progress',
|
||||
requestId: request.requestId,
|
||||
pageNumber: 1
|
||||
} satisfies WorkerOutput)
|
||||
const section = await recognizeImage(request.data, '图片')
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -83,7 +83,6 @@ export const settings = {
|
||||
saveAndTestRuntime: 'Save and test {{runtime}}',
|
||||
saving: 'Saving…',
|
||||
saveSettings: 'Save settings',
|
||||
testParsing: 'Test parsing',
|
||||
testingParsing: 'Parsing…',
|
||||
select: 'Select',
|
||||
selectFile: 'Select file',
|
||||
@@ -238,52 +237,55 @@ export const settings = {
|
||||
conversionUnavailable:
|
||||
'Not implemented yet; DOC, XLS, and PPT are currently unavailable',
|
||||
localOcr: 'Local OCR',
|
||||
localOcrModel: 'Current OCR: {{name}}',
|
||||
ocrReady:
|
||||
'The model is installed, SHA-256 verified, and available offline',
|
||||
ocrUnavailable:
|
||||
'The model is not installed or failed verification. Download it from ModelScope.',
|
||||
partialNotice:
|
||||
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scanned PDFs use local OCR.'
|
||||
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scenario modes can use local OCR for scanned PDFs.'
|
||||
},
|
||||
workflows: {
|
||||
title: 'Usage scenarios',
|
||||
title: 'PDF parsing modes',
|
||||
description:
|
||||
'Choose different parsing depth for chat attachments and knowledge imports',
|
||||
chat: 'Chat attachments',
|
||||
chatDescription:
|
||||
'Controls parsing before an attachment is added to the current request',
|
||||
'Choose how each scenario handles PDF text layers and scanned pages',
|
||||
chat: 'Chat and artifact files',
|
||||
knowledge: 'Knowledge imports',
|
||||
knowledgeDescription:
|
||||
'Controls parsing before chunking, indexing, and source location',
|
||||
testChat: 'Test chat and artifact mode',
|
||||
testKnowledge: 'Test knowledge mode',
|
||||
unsavedNotice:
|
||||
'There are unsaved changes. Save them before testing the active mode.',
|
||||
chatOptions: {
|
||||
auto: 'Automatic parsing (recommended)',
|
||||
fastText: 'Fast text',
|
||||
highFidelity: 'High-fidelity parsing'
|
||||
auto: 'Automatic recognition (recommended)',
|
||||
fastText: 'Text layer only',
|
||||
highFidelity: 'OCR every page'
|
||||
},
|
||||
chatDescriptions: {
|
||||
auto:
|
||||
'Chat attachments and artifact PDFs prefer the text layer and use OCR only on pages without useful text.',
|
||||
fastText:
|
||||
'Chat attachments and artifact PDFs use only the text layer. Scanned documents may be unreadable.',
|
||||
highFidelity:
|
||||
'Run OCR on every PDF page. This is slower.'
|
||||
},
|
||||
knowledgeOptions: {
|
||||
completeIndex: 'Complete indexing (recommended)',
|
||||
fastIndex: 'Fast indexing',
|
||||
highFidelity: 'High-fidelity indexing'
|
||||
completeIndex: 'Automatic recognition (recommended)',
|
||||
fastIndex: 'Text layer only',
|
||||
highFidelity: 'OCR every page'
|
||||
},
|
||||
knowledgeDescriptions: {
|
||||
'complete-index':
|
||||
'Prefer the PDF text layer and use OCR only on pages without useful text.',
|
||||
'fast-index':
|
||||
'Use only the PDF text layer. Scanned pages are not indexed.',
|
||||
'high-fidelity':
|
||||
'Run OCR on every PDF page before chunking and indexing. This is slower.'
|
||||
}
|
||||
},
|
||||
ocr: {
|
||||
title: 'OCR recognition',
|
||||
description:
|
||||
'Install a local model on demand to recognize scanned PDFs on this device',
|
||||
enabled: 'Enable local OCR',
|
||||
enabledDescription:
|
||||
'After installation, the model runs only on this device through ONNX Runtime WebAssembly. Documents are not uploaded for recognition.',
|
||||
model: 'Local model',
|
||||
runtime: 'Runtime',
|
||||
provider: {
|
||||
title: 'OCR source',
|
||||
description:
|
||||
'Choose either a local model or a remote service, then save settings to switch.',
|
||||
local: 'Local model',
|
||||
remote: 'Remote service (coming soon)',
|
||||
remoteDescription:
|
||||
'Remote integrations will support services such as MinerU and PaddleOCR-VL. They are disabled in this version.'
|
||||
},
|
||||
modelSelector: 'Current OCR model',
|
||||
modelSelectorDescription:
|
||||
'This saved model is used for chat attachments and knowledge imports.',
|
||||
@@ -291,6 +293,7 @@ export const settings = {
|
||||
'This model selection is not active yet. Save settings to switch.',
|
||||
installedOption: 'Installed',
|
||||
downloadableOption: 'Available to download',
|
||||
unavailableOption: 'Unavailable in this version',
|
||||
openModelsDirectory: 'Open model folder',
|
||||
storagePrefix: 'Models are installed on demand in',
|
||||
storageSuffix:
|
||||
@@ -314,6 +317,7 @@ export const settings = {
|
||||
},
|
||||
installed: 'Installed and verified',
|
||||
download: 'Download',
|
||||
downloadAndSelect: 'Download and enable',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP',
|
||||
delete: 'Delete',
|
||||
@@ -322,14 +326,12 @@ export const settings = {
|
||||
openRepository: 'Open ModelScope',
|
||||
catalogUnavailable:
|
||||
'No OCR model catalog is available in this version.',
|
||||
mode: 'PDF OCR strategy',
|
||||
modes: {
|
||||
auto: 'Automatic; recognize only pages without useful text',
|
||||
always: 'Always recognize every page',
|
||||
disabled: 'Use only the PDF text layer'
|
||||
},
|
||||
modelLicense:
|
||||
'The model uses Apache License 2.0 and is SHA-256 verified before loading.',
|
||||
selectedModelUnavailable:
|
||||
'The saved OCR model is unavailable in this version. Select and install another model above.',
|
||||
installBeforeSelecting:
|
||||
'Download this model first. It will become the current model after installation.',
|
||||
privacyNotice:
|
||||
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
|
||||
operations: {
|
||||
preparing: 'Preparing model files',
|
||||
downloading: 'Downloading from ModelScope',
|
||||
@@ -347,7 +349,11 @@ export const settings = {
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
installedAndSelected:
|
||||
'{{name}} installed and selected as the current model',
|
||||
importedZip: '{{name}} imported from ZIP',
|
||||
importedAndSelected:
|
||||
'{{name}} imported and selected as the current model',
|
||||
exportedZip: '{{name}} exported as ZIP',
|
||||
removed: 'OCR model deleted'
|
||||
}
|
||||
@@ -355,10 +361,9 @@ export const settings = {
|
||||
advanced: {
|
||||
title: 'Advanced parsing settings',
|
||||
maximumPages: 'Maximum OCR pages per document',
|
||||
concurrency: 'OCR concurrency',
|
||||
timeout: 'OCR time budget per page (seconds)',
|
||||
concurrencyHint:
|
||||
'The WASM baseline currently processes pages serially; this value is reserved for batching and hardware acceleration.'
|
||||
description:
|
||||
'The page limit counts only pages actually sent to OCR. Parsing stops if one page exceeds its time budget.'
|
||||
},
|
||||
diagnostic: {
|
||||
title: 'Parsing test result',
|
||||
|
||||
@@ -74,7 +74,6 @@ export const settings = {
|
||||
saveAndTestRuntime: '保存并测试 {{runtime}}',
|
||||
saving: '保存中…',
|
||||
saveSettings: '保存设置',
|
||||
testParsing: '测试解析',
|
||||
testingParsing: '正在解析…',
|
||||
select: '选择',
|
||||
selectFile: '选择文件',
|
||||
@@ -217,50 +216,55 @@ export const settings = {
|
||||
conversion: '旧版 Office 转换',
|
||||
conversionUnavailable: '尚未实现,DOC、XLS、PPT 暂不可用',
|
||||
localOcr: '本地 OCR',
|
||||
localOcrModel: '当前 OCR:{{name}}',
|
||||
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
|
||||
partialNotice:
|
||||
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 使用本地 OCR。'
|
||||
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
|
||||
},
|
||||
workflows: {
|
||||
title: '使用场景',
|
||||
description: '为聊天附件和知识库选择不同的解析深度',
|
||||
chat: '聊天附件',
|
||||
chatDescription: '控制附件加入当前请求前的解析方式',
|
||||
title: 'PDF 解析模式',
|
||||
description: '直接选择各场景处理 PDF 文本层与扫描页面的方式',
|
||||
chat: '聊天与成果文件',
|
||||
knowledge: '知识库导入',
|
||||
knowledgeDescription: '控制文档分块、索引和来源定位前的解析方式',
|
||||
testChat: '测试聊天与成果模式',
|
||||
testKnowledge: '测试知识库模式',
|
||||
unsavedNotice: '当前有未保存修改;保存后可测试实际生效的模式。',
|
||||
chatOptions: {
|
||||
auto: '自动解析(推荐)',
|
||||
fastText: '快速文本',
|
||||
highFidelity: '高保真解析'
|
||||
auto: '自动识别(推荐)',
|
||||
fastText: '仅使用文本层',
|
||||
highFidelity: '全页 OCR'
|
||||
},
|
||||
chatDescriptions: {
|
||||
auto:
|
||||
'聊天附件和成果 PDF 优先使用文本层,仅对无有效文本的页面使用 OCR。',
|
||||
fastText:
|
||||
'聊天附件和成果 PDF 仅使用文本层,不运行 OCR;扫描件可能无法读取。',
|
||||
highFidelity: '对 PDF 的每一页运行 OCR,速度较慢。'
|
||||
},
|
||||
knowledgeOptions: {
|
||||
completeIndex: '完整索引(推荐)',
|
||||
fastIndex: '快速索引',
|
||||
highFidelity: '高保真索引'
|
||||
completeIndex: '自动识别(推荐)',
|
||||
fastIndex: '仅使用文本层',
|
||||
highFidelity: '全页 OCR'
|
||||
},
|
||||
knowledgeDescriptions: {
|
||||
'complete-index':
|
||||
'优先使用 PDF 文本层,仅对无有效文本的页面使用 OCR。',
|
||||
'fast-index':
|
||||
'仅使用 PDF 文本层,不运行 OCR;扫描页面不会进入索引。',
|
||||
'high-fidelity':
|
||||
'对 PDF 的每一页运行 OCR 后再分块和建立索引,速度较慢。'
|
||||
}
|
||||
},
|
||||
ocr: {
|
||||
title: 'OCR 识别',
|
||||
description: '按需安装本地模型,在设备上识别扫描 PDF',
|
||||
enabled: '启用本地 OCR',
|
||||
enabledDescription:
|
||||
'模型安装后仅在本机通过 ONNX Runtime WebAssembly 运行,识别时不会上传文档。',
|
||||
model: '本地模型',
|
||||
runtime: '运行时',
|
||||
provider: {
|
||||
title: 'OCR 来源',
|
||||
description: '本地模型与远程服务二选一,切换后保存设置生效。',
|
||||
local: '本地模型',
|
||||
remote: '远程服务(即将支持)',
|
||||
remoteDescription:
|
||||
'远程服务将支持 MinerU、PaddleOCR-VL 等接口,当前版本暂不可选。'
|
||||
},
|
||||
modelSelector: '当前 OCR 模型',
|
||||
modelSelectorDescription: '选择已保存,聊天附件和知识库将使用此模型。',
|
||||
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
|
||||
installedOption: '已安装',
|
||||
downloadableOption: '可下载',
|
||||
unavailableOption: '当前版本不可用',
|
||||
openModelsDirectory: '打开模型目录',
|
||||
storagePrefix: '模型按需安装到',
|
||||
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
|
||||
@@ -283,6 +287,7 @@ export const settings = {
|
||||
},
|
||||
installed: '已安装并校验',
|
||||
download: '下载',
|
||||
downloadAndSelect: '下载并启用',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP',
|
||||
delete: '删除',
|
||||
@@ -290,14 +295,11 @@ export const settings = {
|
||||
cancel: '取消',
|
||||
openRepository: '打开 ModelScope',
|
||||
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
|
||||
mode: 'PDF OCR 策略',
|
||||
modes: {
|
||||
auto: '自动,仅识别无有效文本的页面',
|
||||
always: '始终识别所有页面',
|
||||
disabled: '仅使用 PDF 文本层'
|
||||
},
|
||||
modelLicense:
|
||||
'模型采用 Apache License 2.0,并在加载前校验 SHA-256。',
|
||||
selectedModelUnavailable:
|
||||
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
|
||||
installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。',
|
||||
privacyNotice:
|
||||
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
|
||||
operations: {
|
||||
preparing: '正在准备模型文件',
|
||||
downloading: '正在从 ModelScope 下载',
|
||||
@@ -315,7 +317,9 @@ export const settings = {
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
installedAndSelected: '{{name}} 已安装并设为当前模型',
|
||||
importedZip: '{{name}} 已从 ZIP 导入',
|
||||
importedAndSelected: '{{name}} 已导入并设为当前模型',
|
||||
exportedZip: '{{name}} 已导出为 ZIP',
|
||||
removed: 'OCR 模型已删除'
|
||||
}
|
||||
@@ -323,10 +327,9 @@ export const settings = {
|
||||
advanced: {
|
||||
title: '高级解析设置',
|
||||
maximumPages: '单文档最大 OCR 页数',
|
||||
concurrency: 'OCR 并发数',
|
||||
timeout: '每页 OCR 时间预算(秒)',
|
||||
concurrencyHint:
|
||||
'当前 WASM 基线按页串行执行;该值为后续批处理和硬件加速保留。'
|
||||
description:
|
||||
'页数限制只计算实际进入 OCR 的页面;单页超过时间预算时会终止本次解析。'
|
||||
},
|
||||
diagnostic: {
|
||||
title: '解析测试结果',
|
||||
|
||||
@@ -5530,6 +5530,11 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-parsing-workflows .field > label {
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.document-parsing-workflows .field {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
@@ -5559,37 +5564,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.document-ocr-provider {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.document-ocr-provider > div:first-child {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.document-ocr-provider strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.document-ocr-provider small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-ocr-provider > small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.document-ocr-model-selector {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
@@ -5724,49 +5698,9 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options > * {
|
||||
min-height: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options .settings-checkbox {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options .settings-checkbox > input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options .settings-checkbox > span {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options .settings-checkbox strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.document-ocr-settings__options .settings-checkbox small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
.document-parsing-workflow-test {
|
||||
width: fit-content;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic-backdrop {
|
||||
@@ -5866,8 +5800,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
@media (max-width: 720px) {
|
||||
.document-parsing-status__list,
|
||||
.document-parsing-grid,
|
||||
.document-parsing-diagnostic dl,
|
||||
.document-ocr-settings__options {
|
||||
.document-parsing-diagnostic dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user