Files
goodbuddy/src/renderer/src/DocumentParsingSettingsSection.tsx
T
mesalogo 9836a4cc9d
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: keep question order and streamline contextual help
Keep interactive questions and answered cards at their original position in
conversation history, including remote reception, recovery, and reopening.
Preserve legacy history placement without a database migration.

Move supplementary guidance into shared accessible contextual-help popovers
across pages and settings. Add keyboard, modal, accessibility, and Electron
regressions, plus bounded opt-in live canvas-analysis validation.

Remove unused Agent, Runtime, and in-memory graph-search helpers, obsolete
mocks and browser copy; reuse the existing package-size formatter. Keep
production execution paths, protocol schemas, and released data migrations.

Validation: targeted cleanup tests, typecheck, lint, shared Linux Host
attach/read/re-attach, and added help/Electron checks passed. Final full-suite
verification passed 4,858 tests with 67 skipped and zero failures (412 files
passed, 9 skipped). Focused help checks passed 54 tests, including Electron
style comparisons and Chromium accessibility descriptions. Details and
validation boundaries are recorded in feature docs.

Release note: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
2026-09-20 01:26:36 +08:00

1158 lines
40 KiB
TypeScript

import {
CheckCircle2,
Download,
ExternalLink,
FileSearch,
FileText,
FolderOpen,
ScanText,
ShieldCheck,
Square,
Trash2,
TriangleAlert,
Upload,
X
} from 'lucide-react'
import {
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import type {
DocumentParsingDiagnostic,
DocumentOcrModelCatalogViewEntry,
DocumentParsingSettings,
DocumentParsingSnapshot,
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { displayErrorMessage } from './error-message'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { modelOperationPercent } from './model-download-presentation'
import { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { HttpOcrSettings } from './HttpOcrSettings'
import { DocumentResultPreview } from './DocumentResultPreview'
import { AnchoredMenu } from './AnchoredMenu'
type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
onOpenModelDownloadSourceSettings?: () => void
onDirtyChange?: (dirty: boolean) => void
onBusyChange?: (busy: boolean) => void
}
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
: `${(bytes / 1024).toFixed(1)} KB`
}
function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
return entry.files.reduce(
(total, file) => total + file.size,
0
)
}
function StatusRow({
available,
detail,
label,
statusText
}: {
available: boolean
detail: string
label: string
statusText?: string
}): React.JSX.Element {
const { t } = useTranslation('settings')
return (
<div className="document-parsing-status__row">
{available ? (
<CheckCircle2 aria-hidden="true" size={16} />
) : (
<TriangleAlert aria-hidden="true" size={16} />
)}
<span>
<strong>{label}</strong>
<small>{detail}</small>
</span>
<span
className={`document-parsing-status__badge${
available
? ' document-parsing-status__badge--available'
: ''
}`}
>
{statusText ?? (available
? t('documentParsing.status.available')
: t('documentParsing.status.unavailable'))}
</span>
</div>
)
}
function DiagnosticDialog({
diagnostic,
onClose,
restoreFocus
}: {
diagnostic: DocumentParsingDiagnostic
onClose: () => void
restoreFocus: () => HTMLElement | null
}): React.JSX.Element {
const { t } = useTranslation('settings')
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const restoreRef = useRef(restoreFocus)
useEffect(() => {
return activateModalFocus(() => closeRef.current, () => restoreRef.current())
}, [])
return createPortal(
<div
className="document-parsing-diagnostic-backdrop"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
>
<section
aria-labelledby="document-parsing-diagnostic-title"
aria-modal="true"
className="document-parsing-diagnostic"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header>
<div>
<strong id="document-parsing-diagnostic-title">
{t('documentParsing.diagnostic.title')}
</strong>
<small>{diagnostic.fileName}</small>
</div>
<button
aria-label={t('documentParsing.diagnostic.close')}
className="icon-button"
onClick={onClose}
ref={closeRef}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</header>
<dl>
<div>
<dt>{t('documentParsing.diagnostic.format')}</dt>
<dd>{diagnostic.sourceFormat}</dd>
</div>
<div>
<dt>{t('documentParsing.diagnostic.method')}</dt>
<dd>
{diagnostic.provider === 'paddleocr-vl' ? 'HTTP PaddleOCR-VL' : t(
`documentParsing.diagnostic.methods.${diagnostic.method}`
)}
</dd>
</div>
<div>
<dt>{t('documentParsing.diagnostic.pages')}</dt>
<dd>{diagnostic.pageCount}</dd>
</div>
<div>
<dt>{t('documentParsing.diagnostic.ocrPages')}</dt>
<dd>{diagnostic.ocrPageCount}</dd>
</div>
<div>
<dt>{t('documentParsing.diagnostic.characters')}</dt>
<dd>{diagnostic.characterCount.toLocaleString()}</dd>
</div>
<div>
<dt>{t('documentParsing.diagnostic.duration')}</dt>
<dd>{(diagnostic.durationMs / 1_000).toFixed(1)}s</dd>
</div>
</dl>
{diagnostic.resultId ? <DocumentResultPreview key={diagnostic.resultId} resultId={diagnostic.resultId} /> : <div className="document-parsing-diagnostic__preview">
<strong>{t('documentParsing.diagnostic.preview')}</strong>
<pre>{diagnostic.preview}</pre>
</div>}
{!diagnostic.resultId && diagnostic.warnings.length > 0 && (
<div className="settings-warning">
<strong>{t('documentParsing.diagnostic.warnings')}</strong>
<ul>
{diagnostic.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</section>
</div>,
document.body
)
}
export function DocumentParsingSettingsSection({
onNotify,
onOpenModelDownloadSourceSettings,
onDirtyChange,
onBusyChange
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settings')
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
const [draft, setDraft] = useState<DocumentParsingSettings>()
const [error, setError] = useState<string>()
const [saving, setSaving] = useState(false)
const [apiKey, setApiKey] = useState('')
const [clearApiKey, setClearApiKey] = useState(false)
const [testMenuOpen, setTestMenuOpen] = useState(false)
const testButtonRef = useRef<HTMLButtonElement>(null)
const [testingPurpose, setTestingPurpose] =
useState<DocumentParsingTestPurpose>()
const [busyModelId, setBusyModelId] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState<string>()
const [diagnostic, setDiagnostic] =
useState<DocumentParsingDiagnostic>()
const mountedRef = useRef(false)
const testOperation = useRef<string | undefined>(undefined)
const settingsDirty =
snapshot !== undefined &&
draft !== undefined &&
(JSON.stringify(draft) !== JSON.stringify(snapshot.settings) || Boolean(apiKey) || clearApiKey)
useEffect(() => {
onDirtyChange?.(settingsDirty)
}, [onDirtyChange, settingsDirty])
useEffect(() => {
onBusyChange?.(saving || testingPurpose !== undefined)
return () => onBusyChange?.(false)
}, [onBusyChange, saving, testingPurpose])
const refresh = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
throw new Error(t('errors.documentParsingUnavailable'))
}
const next = await api.getSnapshot()
if (mountedRef.current) {
setSnapshot(next)
}
}, [t])
const refreshProgress = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
return
}
const progress = await api.getOcrModelProgress()
if (mountedRef.current) {
setSnapshot((current) =>
current
? {
...current,
ocrModels: {
...current.ocrModels,
operations: progress.operations
}
}
: current
)
}
}, [])
useEffect(() => {
const api = window.goodbuddy.documentParsing
let active = true
mountedRef.current = true
if (!api) {
return () => {
active = false
mountedRef.current = false
}
}
void api.getSnapshot().then(
(next) => {
if (active) {
setSnapshot(next)
setDraft(next.settings)
}
},
(reason: unknown) => {
if (active) {
setError(
displayErrorMessage(
reason,
t('errors.readDocumentParsing')
)
)
}
}
)
return () => {
active = false
mountedRef.current = false
}
}, [t])
const shouldPoll =
busyModelId !== undefined ||
Boolean(snapshot?.ocrModels.operations.length)
useEffect(() => {
if (!shouldPoll) {
return
}
let active = true
let timer: number | undefined
const poll = async (): Promise<void> => {
try {
await refreshProgress()
} catch {
// The final full refresh reports actionable operation errors.
} finally {
if (active) {
timer = window.setTimeout(() => void poll(), 300)
}
}
}
timer = window.setTimeout(() => void poll(), 300)
return () => {
active = false
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [refreshProgress, shouldPoll])
const updateDraft = <Key extends keyof DocumentParsingSettings>(
key: Key,
value: DocumentParsingSettings[Key]
): void => {
setDraft((current) =>
current ? { ...current, [key]: value } : current
)
}
const save = async (): Promise<DocumentParsingSnapshot | undefined> => {
const api = window.goodbuddy.documentParsing
if (!api || !draft) {
return undefined
}
setSaving(true)
setError(undefined)
try {
const next = await api.update({ ...draft, ...(apiKey ? { apiKey } : {}), ...(clearApiKey ? { clearApiKey } : {}) })
setSnapshot(next)
setDraft(next.settings)
setApiKey('')
setClearApiKey(false)
onNotify?.({
tone: 'success',
message: t('notifications.documentParsingSaved'),
dedupeKey: 'document-parsing-saved'
})
return next
} catch (reason) {
setError(
displayErrorMessage(reason, t('errors.saveDocumentParsing'))
)
return undefined
} finally {
setSaving(false)
}
}
const runModelOperation = async (
modelId: string,
operation: () => Promise<DocumentParsingSnapshot | undefined>,
successMessage: string
): Promise<void> => {
setBusyModelId(modelId)
setConfirmingRemove(undefined)
setError(undefined)
try {
const next = await operation()
if (next && mountedRef.current) {
setSnapshot(next)
onNotify?.({
tone: 'success',
message: successMessage,
dedupeKey: `document-ocr-model-${modelId}`
})
}
} catch (reason) {
if (mountedRef.current) {
setError(
displayErrorMessage(
reason,
t('errors.manageDocumentOcrModel')
)
)
}
} finally {
if (mountedRef.current) {
setBusyModelId(undefined)
void refresh().catch(() => undefined)
}
}
}
const removeModel = async (modelId: string): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
return
}
if (confirmingRemove !== modelId) {
setConfirmingRemove(modelId)
return
}
await runModelOperation(
modelId,
() => api.removeOcrModel(modelId),
t('documentParsing.ocr.notifications.removed')
)
}
const testParsing = async (
purpose: DocumentParsingTestPurpose
): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
setError(t('errors.documentParsingUnavailable'))
return
}
setTestingPurpose(purpose)
setError(undefined)
const operationId = crypto.randomUUID()
testOperation.current = operationId
try {
const result = await api.test(purpose, operationId)
if (result) {
if (diagnostic?.resultId) await api.releaseResult(diagnostic.resultId)
setDiagnostic(result)
}
} catch (reason) {
setError(
displayErrorMessage(reason, t('errors.testDocumentParsing'))
)
} finally {
testOperation.current = undefined
setTestingPurpose(undefined)
}
}
if (!snapshot || !draft) {
const unavailableError = window.goodbuddy.documentParsing
? undefined
: t('errors.documentParsingUnavailable')
return (
<>
<SettingsCategoryHeader
category="document-parsing"
error={error ?? unavailableError}
/>
{!error && !unavailableError && (
<p className="settings-empty">
{t('documentParsing.loading')}
</p>
)}
</>
)
}
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
)
const modelDisplayName = model
? t(`documentParsing.ocr.catalog.${model.id}.displayName`, {
defaultValue: model.displayName
})
: ''
const modelDescription = model
? t(`documentParsing.ocr.catalog.${model.id}.description`, {
defaultValue: model.description
})
: ''
const modelOperation = snapshot.ocrModels.operations.find(
(operation) => operation.modelId === draft.localOcrModelId
)
const modelProgress = modelOperation
? modelOperationPercent(modelOperation)
: undefined
const modelDownloadAvailability = model?.downloadAvailability.find(
(availability) =>
availability.source ===
snapshot.ocrModels.selectedDownloadSource
)
const modelSourceAvailable =
modelDownloadAvailability?.available === true
const modelSize = installedModel
? installedModel.files.reduce(
(total, file) => total + file.size,
0
)
: modelDownloadAvailability?.totalBytes ??
(model ? catalogSize(model) : undefined)
const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId
const selectedModelReady = installedModel !== undefined
const invalidPendingModel =
draft.ocrProvider !== 'paddleocr-vl' && pendingModelSelection && !selectedModelReady
const testing = testingPurpose !== undefined
return (
<>
<SettingsCategoryHeader
actions={
<>
<button type="button" className="secondary-button" ref={testButtonRef} disabled={saving || testing || settingsDirty}
aria-describedby={settingsDirty ? 'document-parsing-unsaved-notice' : undefined}
aria-haspopup="menu" aria-expanded={testMenuOpen} onClick={() => setTestMenuOpen((open) => !open)}>
<FileSearch aria-hidden="true" size={14} />{testing ? '正在测试解析' : '测试解析'}
</button>
{testMenuOpen && <AnchoredMenu anchorRef={testButtonRef} id="document-test-menu" label="选择测试场景" onClose={() => setTestMenuOpen(false)}>
<button type="button" role="menuitem" onClick={() => { setTestMenuOpen(false); void testParsing('chat-attachment') }}>{t('documentParsing.workflows.testChat')}</button>
<button type="button" role="menuitem" onClick={() => { setTestMenuOpen(false); void testParsing('knowledge-index') }}>{t('documentParsing.workflows.testKnowledge')}</button>
</AnchoredMenu>}
<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}
/>
<SettingsWarningList warnings={snapshot.warnings} />
{testing && <div role="status" aria-live="polite"><span>正在解析测试文件</span><button type="button" className="secondary-button" onClick={() => {
if (testOperation.current) void window.goodbuddy.documentParsing!.cancelTest(testOperation.current)
}}>取消解析</button></div>}
{settingsDirty && (
<p
className="settings-notice"
id="document-parsing-unsaved-notice"
>
{t('documentParsing.workflows.unsavedNotice')}
</p>
)}
<section
aria-labelledby="document-parsing-status-title"
className="settings-section document-parsing-status"
>
<div className="settings-section__title">
<ShieldCheck aria-hidden="true" size={17} />
<div>
<strong id="document-parsing-status-title">
{t('documentParsing.status.title')}
</strong>
<small>{t('documentParsing.status.description')}</small>
</div>
</div>
<div className="document-parsing-status__list">
<StatusRow
available={snapshot.status.nativeParsingAvailable}
detail={t('documentParsing.status.nativeDetail')}
label={t('documentParsing.status.native')}
/>
{snapshot.settings.ocrProvider === 'paddleocr-vl' ? <StatusRow
available={Boolean(snapshot.settings.httpOcr?.baseUrl)}
statusText={snapshot.settings.httpOcr?.baseUrl ? '已配置' : '未配置'}
label="HTTP PaddleOCR-VL"
detail="远程 OCR;解析能力请使用真实文件测试,与本地模型安装状态无关。"
/> : <StatusRow
available={snapshot.status.localOcr.available}
detail={t(
snapshot.status.localOcr.available
? 'documentParsing.status.ocrReady'
: currentModelInCatalog
? 'documentParsing.status.ocrUnavailable'
: 'documentParsing.ocr.selectedModelUnavailable'
)}
label={t('documentParsing.status.localOcrModel', {
name: snapshot.status.localOcr.displayName
})}
/>}
<StatusRow
available={snapshot.status.conversionAvailable}
detail={t(
'documentParsing.status.conversionUnavailable'
)}
label={t('documentParsing.status.conversion')}
/>
</div>
</section>
<section
aria-labelledby="document-parsing-workflows-title"
className="settings-section document-parsing-workflows"
>
<div className="settings-section__title">
<FileText aria-hidden="true" size={17} />
<div>
<span className="inline-help-label">
<strong id="document-parsing-workflows-title">
{t('documentParsing.workflows.title')}
</strong>
<InlineHelp label={t('documentParsing.workflows.title')}>{t('documentParsing.workflows.description')}</InlineHelp>
</span>
</div>
</div>
<div className="document-parsing-grid">
<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',
event.target
.value as DocumentParsingSettings['chatWorkflow']
)
}
value={draft.chatWorkflow}
>
<option value="auto">
{t('documentParsing.workflows.chatOptions.auto')}
</option>
<option value="fast-text">
{t('documentParsing.workflows.chatOptions.fastText')}
</option>
<option value="high-fidelity">
{t(
'documentParsing.workflows.chatOptions.highFidelity'
)}
</option>
</select>
<small>
{t(
`documentParsing.workflows.chatDescriptions.${draft.chatWorkflow === 'fast-text' ? 'fastText' : draft.chatWorkflow === 'high-fidelity' ? 'highFidelity' : 'auto'}`
)}
</small>
</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',
event.target
.value as DocumentParsingSettings['knowledgeWorkflow']
)
}
value={draft.knowledgeWorkflow}
>
<option value="complete-index">
{t(
'documentParsing.workflows.knowledgeOptions.completeIndex'
)}
</option>
<option value="fast-index">
{t(
'documentParsing.workflows.knowledgeOptions.fastIndex'
)}
</option>
<option value="high-fidelity">
{t(
'documentParsing.workflows.knowledgeOptions.highFidelity'
)}
</option>
</select>
<small>
{t(
`documentParsing.workflows.knowledgeDescriptions.${draft.knowledgeWorkflow}`
)}
</small>
</div>
</div>
</section>
<section className="settings-section">
<strong>OCR 来源</strong>
<SegmentedControl ariaLabel="OCR 来源" value={draft.ocrProvider ?? 'local'}
options={[{ value: 'local', label: '本地模型' }, { value: 'paddleocr-vl', label: '远程服务' }]}
onChange={(value) => updateDraft('ocrProvider', value)} />
<small>已保存来源:{snapshot.settings.ocrProvider === 'paddleocr-vl' ? 'HTTP PaddleOCR-VL' : '本地模型'}</small>
</section>
{draft.ocrProvider === 'paddleocr-vl' ? <HttpOcrSettings
value={draft.httpOcr ?? defaultHttpOcrSettings} onChange={(value) => updateDraft('httpOcr', value)}
dirty={settingsDirty || saving || testing} credentialConfigured={snapshot.httpCredentialConfigured ?? false}
apiKey={apiKey} onApiKey={setApiKey} clearApiKey={clearApiKey} onClearApiKey={setClearApiKey}
/> : <section
aria-labelledby="document-parsing-ocr-title"
className="settings-section document-ocr-settings"
>
<div className="settings-section__title settings-section__title--actions">
<ScanText aria-hidden="true" size={17} />
<div>
<span className="inline-help-label">
<strong id="document-parsing-ocr-title">
{t('documentParsing.ocr.title')}
</strong>
<InlineHelp label={t('documentParsing.ocr.title')}>{t('documentParsing.ocr.description')}</InlineHelp>
</span>
</div>
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.documentParsing
?.openOcrModelsDirectory()
}
type="button"
>
<FolderOpen aria-hidden="true" size={13} />
{t('documentParsing.ocr.openModelsDirectory')}
</button>
</div>
<label className="field document-ocr-model-selector">
<span>{t('documentParsing.ocr.modelSelector')}</span>
<select
aria-label={t('documentParsing.ocr.modelSelector')}
onChange={(event) =>
updateDraft('localOcrModelId', event.target.value)
}
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
)
const entryDisplayName = t(
`documentParsing.ocr.catalog.${entry.id}.displayName`,
{ defaultValue: entry.displayName }
)
return (
<option key={entry.id} value={entry.id}>
{entryDisplayName} ·{' '}
{installed
? t('documentParsing.ocr.installedOption')
: entry.downloadAvailability.some(
(availability) =>
availability.source ===
snapshot.ocrModels
.selectedDownloadSource &&
availability.available
)
? t('documentParsing.ocr.downloadableOption')
: t(
'documentParsing.ocr.sourceUnavailableOption'
)}
</option>
)
})}
</select>
<small>
{invalidPendingModel
? t('documentParsing.ocr.installBeforeSelecting')
: pendingModelSelection
? t('documentParsing.ocr.pendingSelection')
: t('documentParsing.ocr.modelSelectorDescription')}
</small>
</label>
{model ? (
<article className="document-ocr-model">
<div className="document-ocr-model__header">
<div className="document-ocr-model__summary">
<div className="document-ocr-model__name">
<strong>{modelDisplayName}</strong>
{model.recommended && (
<span className="speech-model-tag speech-model-tag--recommended">
{t('documentParsing.ocr.recommended')}
</span>
)}
</div>
<p>{modelDescription}</p>
<div className="document-ocr-model__tags">
<span className="speech-model-tag">
{t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)}
</span>
<span className="speech-model-tag">
{model.languages
.map((language) =>
t(
`documentParsing.ocr.languages.${language}`,
{ defaultValue: language }
)
)
.join(' / ')}
</span>
<span className="speech-model-tag">
{t('documentParsing.ocr.quality.label', {
value: t(
`documentParsing.ocr.quality.values.${model.quality}`
)
})}
</span>
<span className="speech-model-tag">
{t('documentParsing.ocr.speed.label', {
value: t(
`documentParsing.ocr.speed.values.${model.speed}`
)
})}
</span>
<span className="speech-model-tag">
{formatBytes(
modelSize ?? catalogSize(model)
)}
</span>
<span className="speech-model-tag">
{model.license.name}
</span>
</div>
</div>
<button
aria-label={t(
'documentParsing.ocr.accessibility.openRepository',
{
name: modelDisplayName,
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
}
)}
className="secondary-button document-ocr-model__repository"
disabled={!modelSourceAvailable}
onClick={() =>
void window.goodbuddy.documentParsing
?.openOcrModelRepository(model.id)
}
type="button"
>
<ExternalLink aria-hidden="true" size={13} />
{t('documentParsing.ocr.openRepository', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</button>
</div>
{modelOperation && (
<div className="document-ocr-model__state">
<span
className="document-ocr-model__status"
>
{t(
modelOperation.phase === 'installing'
? 'documentParsing.ocr.operations.installing'
: modelOperation.kind === 'import'
? 'documentParsing.ocr.operations.importing'
: 'documentParsing.ocr.operations.downloading',
{
source: t(
`modelDownloadSources.${modelOperation.downloadSource}`
)
}
)}
</span>
</div>
)}
<div className="document-ocr-model__actions">
{modelOperation ? (
<button
aria-label={t(
'documentParsing.ocr.accessibility.cancelOperation',
{ name: modelDisplayName }
)}
className="secondary-button"
onClick={() =>
void window.goodbuddy.documentParsing
?.cancelOcrModelOperation(model.id)
.then(() => refresh())
}
type="button"
>
<Square aria-hidden="true" size={12} />
{t('documentParsing.ocr.cancel')}
</button>
) : installedModel ? (
<>
<button
aria-label={t(
'documentParsing.ocr.accessibility.exportModelZip',
{ name: modelDisplayName }
)}
className="secondary-button"
disabled={busyModelId === model.id}
onClick={() =>
void runModelOperation(
model.id,
() =>
window.goodbuddy.documentParsing!
.exportOcrModelArchive(model.id),
t(
'documentParsing.ocr.notifications.exportedZip',
{ name: modelDisplayName }
)
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t('documentParsing.ocr.exportZip')}
</button>
<button
aria-label={t(
'documentParsing.ocr.accessibility.deleteModel',
{ name: modelDisplayName }
)}
className={
confirmingRemove === model.id
? 'danger-button'
: 'danger-ghost'
}
disabled={busyModelId === model.id}
onClick={() => void removeModel(model.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === model.id
? t('documentParsing.ocr.confirmDelete')
: t('documentParsing.ocr.delete')}
</button>
</>
) : (
<>
{modelSourceAvailable && (
<button
aria-label={t(
'documentParsing.ocr.accessibility.downloadModel',
{ name: modelDisplayName }
)}
className="primary-button"
disabled={busyModelId === model.id}
onClick={() =>
void runModelOperation(
model.id,
async () => {
const installed =
await window.goodbuddy.documentParsing!
.installOcrModel(
model.id,
snapshot.ocrModels
.selectedDownloadSource
)
if (!pendingModelSelection) {
return installed
}
return window.goodbuddy.documentParsing!
.update(draft)
},
t(
pendingModelSelection
? 'documentParsing.ocr.notifications.installedAndSelected'
: 'documentParsing.ocr.notifications.installed',
{ name: modelDisplayName }
)
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{pendingModelSelection
? t('documentParsing.ocr.downloadAndSelect')
: t('documentParsing.ocr.download')}
</button>
)}
{!modelSourceAvailable &&
onOpenModelDownloadSourceSettings && (
<button
className="secondary-button"
onClick={
onOpenModelDownloadSourceSettings
}
type="button"
>
{t(
'documentParsing.ocr.openDownloadSourceSettings'
)}
</button>
)}
<button
aria-label={t(
'documentParsing.ocr.accessibility.importModelZip',
{ name: modelDisplayName }
)}
className="secondary-button"
disabled={busyModelId === model.id}
onClick={() =>
void runModelOperation(
model.id,
async () => {
const imported =
await window.goodbuddy.documentParsing!
.importOcrModelArchive(model.id)
if (!imported || !pendingModelSelection) {
return imported
}
return window.goodbuddy.documentParsing!
.update(draft)
},
t(
pendingModelSelection
? 'documentParsing.ocr.notifications.importedAndSelected'
: 'documentParsing.ocr.notifications.importedZip',
{ name: modelDisplayName }
)
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t('documentParsing.ocr.importZip')}
</button>
</>
)}
</div>
{!installedModel && !modelSourceAvailable && (
<p className="settings-warning">
{t('documentParsing.ocr.sourceUnavailableDescription', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</p>
)}
{modelOperation && (
<div
aria-live="polite"
className="document-ocr-model__operation"
>
<progress
aria-label={t(
'documentParsing.ocr.accessibility.downloadProgress',
{ name: modelDisplayName }
)}
max={100}
{...(modelProgress === undefined
? {}
: { value: modelProgress })}
/>
<small>
{modelOperation.currentFile ??
t('documentParsing.ocr.operations.preparing')}
{modelProgress === undefined
? ''
: ` · ${modelProgress.toFixed(0)}%`}
</small>
</div>
)}
</article>
) : (
<p className="settings-warning">
{t(
snapshot.ocrModels.catalog.length === 0
? 'documentParsing.ocr.catalogUnavailable'
: 'documentParsing.ocr.selectedModelUnavailable'
)}
</p>
)}
<p className="settings-notice">
{t('documentParsing.ocr.privacyNotice')}
</p>
</section>}
<details className="settings-section">
<summary>{t('documentParsing.advanced.title')}</summary>
<div className="document-parsing-grid">
<label className="field">
<span>{t('documentParsing.advanced.maximumPages')}</span>
<input
max={500}
min={1}
onChange={(event) =>
updateDraft(
'maximumPages',
Number(event.target.value)
)
}
type="number"
value={draft.maximumPages}
/>
</label>
<label className="field">
<span>{t('documentParsing.advanced.timeout')}</span>
<input
max={300}
min={10}
onChange={(event) =>
updateDraft(
'pageTimeoutSeconds',
Number(event.target.value)
)
}
type="number"
value={draft.pageTimeoutSeconds}
/>
</label>
</div>
<small>{t('documentParsing.advanced.description')}</small>
</details>
{diagnostic && (
<DiagnosticDialog
diagnostic={diagnostic}
restoreFocus={() => testButtonRef.current}
onClose={() => {
if (diagnostic.resultId) void window.goodbuddy.documentParsing!.releaseResult(diagnostic.resultId)
setDiagnostic(undefined)
}}
/>
)}
</>
)
}