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
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: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
1158 lines
40 KiB
TypeScript
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)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|