fix: streamline document parsing settings

This commit is contained in:
lofyer
2026-08-13 00:08:15 +08:00
parent d33df979da
commit 86b63406c2
21 changed files with 1160 additions and 509 deletions
+89
View File
@@ -50,4 +50,93 @@ describe('DocumentOcrBroker', () => {
).toThrow('OCR 解析已取消') ).toThrow('OCR 解析已取消')
broker.dispose() broker.dispose()
}) })
it('rejects requests whose selected OCR pages exceed the limit', () => {
const broker = new DocumentOcrBroker({
isDestroyed: vi.fn(() => false),
webContents: { send: vi.fn() }
} as never)
expect(() =>
broker.recognize({
...request(),
maximumPages: 1,
pageNumbers: [1, 2]
})
).toThrow('OCR 页数超过当前文档限制')
broker.dispose()
})
it('queues OCR requests and starts timeout accounting on dispatch', async () => {
const send = vi.fn()
const broker = new DocumentOcrBroker({
isDestroyed: vi.fn(() => false),
webContents: { send }
} as never)
const first = broker.recognize(request())
const second = broker.recognize({
...request(),
fileName: 'second.pdf'
})
const requests = send.mock.calls.filter(
([channel]) => channel === ipcChannels.documentParsingOcrRequest
)
expect(requests).toHaveLength(1)
const firstRequest = requests[0]?.[1] as {
requestId: string
}
broker.respond({
requestId: firstRequest.requestId,
sections: [],
pageCount: 1,
warnings: []
})
await expect(first).resolves.toEqual(
expect.objectContaining({ requestId: firstRequest.requestId })
)
const dispatched = send.mock.calls.filter(
([channel]) => channel === ipcChannels.documentParsingOcrRequest
)
expect(dispatched).toHaveLength(2)
const secondRequest = dispatched[1]?.[1] as {
requestId: string
}
broker.respond({
requestId: secondRequest.requestId,
sections: [],
pageCount: 1,
warnings: []
})
await expect(second).resolves.toEqual(
expect.objectContaining({ requestId: secondRequest.requestId })
)
broker.dispose()
})
it('cancels a queued request without interrupting the active request', async () => {
const send = vi.fn()
const broker = new DocumentOcrBroker({
isDestroyed: vi.fn(() => false),
webContents: { send }
} as never)
const active = broker.recognize(request())
const controller = new AbortController()
const queued = broker.recognize(
{ ...request(), fileName: 'queued.pdf' },
controller.signal
)
controller.abort()
await expect(queued).rejects.toThrow('OCR 解析已取消')
expect(
send.mock.calls.filter(
([channel]) => channel === ipcChannels.documentParsingOcrCancel
)
).toHaveLength(0)
broker.dispose()
await expect(active).rejects.toThrow('OCR 解析已取消')
})
}) })
+111 -37
View File
@@ -9,17 +9,23 @@ import {
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
type PendingRequest = { type PendingRequest = {
request: DocumentOcrRequest
resolve: (result: DocumentOcrResult) => void resolve: (result: DocumentOcrResult) => void
reject: (error: Error) => void reject: (error: Error) => void
timer: ReturnType<typeof setTimeout> timer?: ReturnType<typeof setTimeout>
timeoutMs: number
detachAbort: () => void detachAbort: () => void
dispatched: boolean
} }
const maximumPendingRequests = 4 const maximumPendingRequests = 4
const maximumTotalTimeoutMs = 10 * 60 * 1_000 const maximumTotalTimeoutMs = 10 * 60 * 1_000
const workerStartupTimeoutMs = 60 * 1_000
export class DocumentOcrBroker { export class DocumentOcrBroker {
private readonly pending = new Map<string, PendingRequest>() private readonly pending = new Map<string, PendingRequest>()
private readonly queue: string[] = []
private activeRequestId?: string
private disposed = false private disposed = false
constructor(private readonly window: BrowserWindow) {} constructor(private readonly window: BrowserWindow) {}
@@ -41,50 +47,37 @@ export class DocumentOcrBroker {
if (signal?.aborted) { if (signal?.aborted) {
throw new Error('OCR 解析已取消') throw new Error('OCR 解析已取消')
} }
const pageCount =
request.pageNumbers?.length ?? request.maximumPages
const timeoutMs = Math.min( const timeoutMs = Math.min(
maximumTotalTimeoutMs, maximumTotalTimeoutMs,
Math.max( Math.max(
request.pageTimeoutSeconds * 1_000, request.pageTimeoutSeconds * 1_000,
request.pageTimeoutSeconds * workerStartupTimeoutMs +
request.maximumPages * request.pageTimeoutSeconds *
1_000 pageCount *
1_000
) )
) )
return new Promise<DocumentOcrResult>((resolve, reject) => { return new Promise<DocumentOcrResult>((resolve, reject) => {
const cancel = (message: string): void => { const onAbort = (): void =>
const pending = this.pending.get(request.requestId) this.cancelRequest(request.requestId, 'OCR 解析已取消')
if (!pending) {
return
}
clearTimeout(pending.timer)
pending.detachAbort()
this.pending.delete(request.requestId)
this.window.webContents.send(
ipcChannels.documentParsingOcrCancel,
request.requestId
)
reject(new Error(message))
}
const timer = setTimeout(() => {
cancel('OCR 解析超时')
}, timeoutMs)
const onAbort = (): void => cancel('OCR 解析已取消')
signal?.addEventListener('abort', onAbort, { once: true }) signal?.addEventListener('abort', onAbort, { once: true })
this.pending.set(request.requestId, { this.pending.set(request.requestId, {
request,
resolve, resolve,
reject, reject,
timer, timeoutMs,
detachAbort: () => detachAbort: () =>
signal?.removeEventListener('abort', onAbort) signal?.removeEventListener('abort', onAbort),
dispatched: false
}) })
this.queue.push(request.requestId)
if (signal?.aborted) { if (signal?.aborted) {
cancel('OCR 解析已取消') this.cancelRequest(request.requestId, 'OCR 解析已取消')
return return
} }
this.window.webContents.send( this.dispatchNext()
ipcChannels.documentParsingOcrRequest,
request
)
}) })
} }
@@ -102,30 +95,111 @@ export class DocumentOcrBroker {
throw new Error('OCR 响应无效') throw new Error('OCR 响应无效')
} }
const pending = this.pending.get(requestId) const pending = this.pending.get(requestId)
if (!pending) { if (!pending || !pending.dispatched) {
return return
} }
clearTimeout(pending.timer)
pending.detachAbort()
this.pending.delete(requestId)
if (result.success) { if (result.success) {
pending.resolve(result.data) this.finishRequest(requestId, () =>
pending.resolve(result.data)
)
} else { } else {
if (!failure?.success) { if (!failure?.success) {
pending.reject(new Error('OCR 响应无效')) this.finishRequest(requestId, () =>
pending.reject(new Error('OCR 响应无效'))
)
return return
} }
pending.reject(new Error(failure.data.error)) this.finishRequest(requestId, () =>
pending.reject(new Error(failure.data.error))
)
} }
} }
dispose(): void { dispose(): void {
this.disposed = true this.disposed = true
for (const pending of this.pending.values()) { for (const pending of this.pending.values()) {
clearTimeout(pending.timer) if (pending.timer) {
clearTimeout(pending.timer)
}
pending.detachAbort() pending.detachAbort()
pending.reject(new Error('OCR 解析已取消')) pending.reject(new Error('OCR 解析已取消'))
} }
this.pending.clear() this.pending.clear()
this.queue.length = 0
this.activeRequestId = undefined
}
private dispatchNext(): void {
if (
this.disposed ||
this.activeRequestId ||
this.window.isDestroyed()
) {
return
}
let requestId = this.queue.shift()
while (requestId && !this.pending.has(requestId)) {
requestId = this.queue.shift()
}
if (!requestId) {
return
}
const pending = this.pending.get(requestId)
if (!pending) {
return
}
this.activeRequestId = requestId
pending.dispatched = true
pending.timer = setTimeout(() => {
this.cancelRequest(requestId, 'OCR 解析超时')
}, pending.timeoutMs)
try {
this.window.webContents.send(
ipcChannels.documentParsingOcrRequest,
pending.request
)
} catch (error) {
const detail =
error instanceof Error ? error.message : 'OCR 渲染服务不可用'
this.finishRequest(requestId, () =>
pending.reject(new Error(detail))
)
}
}
private cancelRequest(requestId: string, message: string): void {
const pending = this.pending.get(requestId)
if (!pending) {
return
}
if (pending.dispatched && !this.window.isDestroyed()) {
this.window.webContents.send(
ipcChannels.documentParsingOcrCancel,
requestId
)
}
this.finishRequest(requestId, () =>
pending.reject(new Error(message))
)
}
private finishRequest(
requestId: string,
settle: () => void
): void {
const pending = this.pending.get(requestId)
if (!pending) {
return
}
if (pending.timer) {
clearTimeout(pending.timer)
}
pending.detachAbort()
this.pending.delete(requestId)
if (this.activeRequestId === requestId) {
this.activeRequestId = undefined
}
settle()
this.dispatchNext()
} }
} }
@@ -163,6 +163,17 @@ afterEach(async () => {
}) })
describe('DocumentOcrModelManager', () => { describe('DocumentOcrModelManager', () => {
it('reports a removed catalog model as unavailable', async () => {
const { manager } = await createManager()
await expect(manager.getStatus('retired-model')).resolves.toMatchObject({
id: 'retired-model',
available: false,
verified: false,
detail: expect.stringContaining('不再提供')
})
})
it('uses immutable SHA-256 verified ModelScope catalog files', () => { it('uses immutable SHA-256 verified ModelScope catalog files', () => {
expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3) expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3)
expect( expect(
+12 -1
View File
@@ -205,7 +205,18 @@ export class DocumentOcrModelManager {
async getStatus( async getStatus(
modelId: string modelId: string
): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> { ): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> {
const entry = this.requireCatalogEntry(modelId) const id = localOcrModelIdSchema.parse(modelId)
const entry = this.catalog.find((candidate) => candidate.id === id)
if (!entry) {
return documentParsingModelStatusSchema.parse({
id,
displayName: id,
available: false,
verified: false,
runtime: 'onnxruntime-web-wasm',
detail: '当前版本不再提供此 OCR 模型,请选择其他模型'
})
}
try { try {
await this.getVerifiedStatus(entry) await this.getVerifiedStatus(entry)
return documentParsingModelStatusSchema.parse({ return documentParsingModelStatusSchema.parse({
+162 -30
View File
@@ -4,14 +4,29 @@ import {
} from './document-parsing-settings-store' } from './document-parsing-settings-store'
import { DocumentParsingService } from './document-parsing-service' import { DocumentParsingService } from './document-parsing-service'
function createPdfFixture(text: string): Buffer { function createPdfFixture(...pageTexts: string[]): Buffer {
const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET` const texts = pageTexts.length > 0 ? pageTexts : ['']
const fontObjectId = texts.length + 3
const firstContentObjectId = fontObjectId + 1
const objects = [ const objects = [
'<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>', `<< /Type /Pages /Kids [${texts
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', .map((_, index) => `${index + 3} 0 R`)
.join(' ')}] /Count ${texts.length} >>`,
...texts.map(
(_, index) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] ' +
`/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` +
`/Contents ${firstContentObjectId + index} 0 R >>`
),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream` ...texts.map((text) => {
const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET`
return (
`<< /Length ${Buffer.byteLength(stream)} >>\n` +
`stream\n${stream}\nendstream`
)
})
] ]
let content = '%PDF-1.4\n' let content = '%PDF-1.4\n'
const offsets = [0] const offsets = [0]
@@ -33,41 +48,62 @@ function createPdfFixture(text: string): Buffer {
function createService(overrides?: { function createService(overrides?: {
settings?: Partial<typeof defaultDocumentParsingSettings> settings?: Partial<typeof defaultDocumentParsingSettings>
modelStatus?: {
available: boolean
verified: boolean
detail: string
}
recognize?: () => Promise<{
requestId: string
sections: Array<{
locator: string
content: string
confidence: number
}>
pageCount: number
warnings: string[]
}>
}) { }) {
const settings = { const settings = {
...defaultDocumentParsingSettings, ...defaultDocumentParsingSettings,
...overrides?.settings ...overrides?.settings
} }
const recognize = vi.fn(async () => ({ const recognize = vi.fn(
requestId: crypto.randomUUID(), overrides?.recognize ??
sections: [ (async () => ({
{ requestId: crypto.randomUUID(),
locator: '第 1 页', sections: [
content: '扫描件识别正文', {
confidence: 0.93 locator: '第 1 页',
} content: '扫描件识别正文',
], confidence: 0.93
pageCount: 1, }
warnings: [] ],
})) pageCount: 1,
const service = new DocumentParsingService( warnings: []
{ }))
get: vi.fn(async () => settings), )
update: vi.fn(async () => settings) const settingsStore = {
} as never, get: vi.fn(async () => settings),
{ update: vi.fn(async () => settings)
getStatus: vi.fn(async () => ({ }
const modelManager = {
getStatus: vi.fn(async () => ({
id: 'pp-ocrv6-tiny', id: 'pp-ocrv6-tiny',
displayName: 'PP-OCRv6 Tiny', displayName: 'PP-OCRv6 Tiny',
available: true, available: overrides?.modelStatus?.available ?? true,
verified: true, verified: overrides?.modelStatus?.verified ?? true,
runtime: 'onnxruntime-web-wasm', runtime: 'onnxruntime-web-wasm',
detail: '可用' detail: overrides?.modelStatus?.detail ?? '可用'
})) })),
} as never, getSnapshot: vi.fn()
}
const service = new DocumentParsingService(
settingsStore as never,
modelManager as never,
{ recognize } as never { recognize } as never
) )
return { recognize, service } return { modelManager, recognize, service, settingsStore }
} }
describe('DocumentParsingService', () => { describe('DocumentParsingService', () => {
@@ -127,4 +163,100 @@ describe('DocumentParsingService', () => {
).rejects.toThrow('未启用 OCR') ).rejects.toThrow('未启用 OCR')
expect(recognize).not.toHaveBeenCalled() expect(recognize).not.toHaveBeenCalled()
}) })
it('falls back to useful native text when automatic OCR is unavailable', async () => {
const { recognize, service } = createService({
modelStatus: {
available: false,
verified: false,
detail: '模型尚未安装'
}
})
const pdf = createPdfFixture('Native PDF body text', '')
const originalExtract = await service.parse(
'native.pdf',
pdf,
'chat-attachment'
)
expect(originalExtract.content).toContain('Native PDF body text')
expect(originalExtract.warnings).toEqual([
expect.stringContaining('模型尚未安装')
])
expect(recognize).not.toHaveBeenCalled()
})
it('falls back to native text when automatic OCR fails', async () => {
const { service } = createService({
recognize: async () => {
throw new Error('OCR runtime failed')
}
})
const parsed = await service.parse(
'native.pdf',
createPdfFixture('Native PDF body text', ''),
'chat-attachment'
)
expect(parsed.content).toContain('Native PDF body text')
expect(parsed.warnings).toEqual([
expect.stringContaining('OCR runtime failed')
])
})
it('limits the number of pages sent to OCR rather than total PDF pages', async () => {
const { recognize, service } = createService({
settings: { maximumPages: 1 }
})
await service.parse(
'mixed.pdf',
createPdfFixture('Native PDF body text', ''),
'chat-attachment'
)
expect(recognize).toHaveBeenCalledWith(
expect.objectContaining({
maximumPages: 1,
pageNumbers: [2]
})
)
})
it('rejects high-fidelity parsing when OCR pages exceed the limit', async () => {
const { recognize, service } = createService({
settings: {
chatWorkflow: 'high-fidelity',
maximumPages: 1
}
})
await expect(
service.parse(
'two-pages.pdf',
createPdfFixture('First page text', 'Second page text'),
'chat-attachment'
)
).rejects.toThrow('有 2 页需要 OCR')
expect(recognize).not.toHaveBeenCalled()
})
it('rejects selecting an OCR model that is not installed', async () => {
const { modelManager, service, settingsStore } = createService()
modelManager.getStatus.mockResolvedValueOnce({
id: 'pp-ocrv6-medium',
displayName: 'PP-OCRv6 Medium',
available: false,
verified: false,
runtime: 'onnxruntime-web-wasm',
detail: '模型尚未安装'
})
await expect(
service.update({
...defaultDocumentParsingSettings,
localOcrModelId: 'pp-ocrv6-medium'
})
).rejects.toThrow('请先安装并校验')
expect(settingsStore.update).not.toHaveBeenCalled()
})
}) })
+53 -11
View File
@@ -1,6 +1,7 @@
import { extname } from 'node:path' import { extname } from 'node:path'
import { import {
documentParsingDiagnosticSchema, documentParsingDiagnosticSchema,
documentParsingSettingsUpdateSchema,
documentParsingSnapshotSchema, documentParsingSnapshotSchema,
type DocumentParsingDiagnostic, type DocumentParsingDiagnostic,
type DocumentParsingPurpose, type DocumentParsingPurpose,
@@ -52,9 +53,10 @@ function hasUsefulText(content: string): boolean {
function effectiveOcrMode( function effectiveOcrMode(
settings: DocumentParsingSettings, settings: DocumentParsingSettings,
purpose: DocumentParsingPurpose purpose: DocumentParsingPurpose
): DocumentParsingSettings['pdfOcrMode'] { ): 'auto' | 'always' | 'disabled' {
if ( if (
(purpose === 'chat-attachment' && ((purpose === 'chat-attachment' ||
purpose === 'artifact-import') &&
settings.chatWorkflow === 'fast-text') || settings.chatWorkflow === 'fast-text') ||
(purpose === 'knowledge-index' && (purpose === 'knowledge-index' &&
settings.knowledgeWorkflow === 'fast-index') settings.knowledgeWorkflow === 'fast-index')
@@ -62,14 +64,15 @@ function effectiveOcrMode(
return 'disabled' return 'disabled'
} }
if ( if (
(purpose === 'chat-attachment' && ((purpose === 'chat-attachment' ||
purpose === 'artifact-import') &&
settings.chatWorkflow === 'high-fidelity') || settings.chatWorkflow === 'high-fidelity') ||
(purpose === 'knowledge-index' && (purpose === 'knowledge-index' &&
settings.knowledgeWorkflow === 'high-fidelity') settings.knowledgeWorkflow === 'high-fidelity')
) { ) {
return 'always' return 'always'
} }
return settings.pdfOcrMode return 'auto'
} }
function buildPdfDocument( function buildPdfDocument(
@@ -130,7 +133,21 @@ export class DocumentParsingService {
} }
async update(input: unknown): Promise<DocumentParsingSnapshot> { async update(input: unknown): Promise<DocumentParsingSnapshot> {
await this.settingsStore.update(input) const nextSettings =
documentParsingSettingsUpdateSchema.parse(input)
const currentSettings = await this.settingsStore.get()
if (
nextSettings.localOcrModelId !==
currentSettings.localOcrModelId
) {
const status = await this.modelManager.getStatus(
nextSettings.localOcrModelId
)
if (!status.available || !status.verified) {
throw new Error('请先安装并校验所选 OCR 模型')
}
}
await this.settingsStore.update(nextSettings)
return this.snapshot() return this.snapshot()
} }
@@ -159,7 +176,7 @@ export class DocumentParsingService {
? pagesWithoutUsefulText ? pagesWithoutUsefulText
: [] : []
if (mode === 'disabled' || !settings.localOcrEnabled) { if (mode === 'disabled') {
const native = nativePdfSections(pages) const native = nativePdfSections(pages)
if (native.length > 0) { if (native.length > 0) {
return buildPdfDocument( return buildPdfDocument(
@@ -182,15 +199,24 @@ export class DocumentParsingService {
pages.length pages.length
) )
} }
if (pages.length > settings.maximumPages) { if (ocrPageNumbers.length > settings.maximumPages) {
throw new Error( throw new Error(
`PDF ${pages.length},超过本地 OCR ${settings.maximumPages} 页限制` `PDF ${ocrPageNumbers.length}需要 OCR,超过 ${settings.maximumPages} 页限制`
) )
} }
const native = nativePdfSections(pages)
const modelStatus = await this.modelManager.getStatus( const modelStatus = await this.modelManager.getStatus(
settings.localOcrModelId settings.localOcrModelId
) )
if (!modelStatus.available || !modelStatus.verified) { if (!modelStatus.available || !modelStatus.verified) {
if (
mode === 'auto' &&
native.some((section) => hasUsefulText(section.content))
) {
return buildPdfDocument(name, native, pages.length, [
`本地 OCR 不可用,已保留 PDF 文本层内容:${modelStatus.detail}`
])
}
throw new Error(modelStatus.detail) throw new Error(modelStatus.detail)
} }
@@ -203,9 +229,25 @@ export class DocumentParsingService {
pageNumbers: ocrPageNumbers, pageNumbers: ocrPageNumbers,
pageTimeoutSeconds: settings.pageTimeoutSeconds pageTimeoutSeconds: settings.pageTimeoutSeconds
} }
const ocr = await (signal let ocr
? this.ocrBroker.recognize(ocrRequest, signal) try {
: this.ocrBroker.recognize(ocrRequest)) ocr = await (signal
? this.ocrBroker.recognize(ocrRequest, signal)
: this.ocrBroker.recognize(ocrRequest))
} catch (error) {
ensureNotAborted(signal)
if (
mode === 'auto' &&
native.some((section) => hasUsefulText(section.content))
) {
const detail =
error instanceof Error ? error.message : '本地 OCR 识别失败'
return buildPdfDocument(name, native, pages.length, [
`本地 OCR 失败,已保留 PDF 文本层内容:${detail}`
])
}
throw error
}
ensureNotAborted(signal) ensureNotAborted(signal)
const ocrByLocator = new Map( const ocrByLocator = new Map(
ocr.sections.map((section) => [section.locator, section]) ocr.sections.map((section) => [section.locator, section])
@@ -60,7 +60,7 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.update(settings)).resolves.toEqual(settings) await expect(store.update(settings)).resolves.toEqual(settings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 2, version: 3,
...settings ...settings
}) })
await expect( await expect(
@@ -68,27 +68,59 @@ describe('DocumentParsingSettingsStore', () => {
).resolves.toEqual(settings) ).resolves.toEqual(settings)
}) })
it('migrates legacy cloud permissions to the local OCR provider', async () => { it('migrates version 2 OCR switches into scenario modes', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 2,
chatWorkflow: 'auto',
knowledgeWorkflow: 'complete-index',
pdfOcrMode: 'always',
ocrProvider: 'local',
localOcrEnabled: true,
localOcrModelId: 'pp-ocrv6-small',
maximumPages: 42,
ocrConcurrency: 4,
pageTimeoutSeconds: 90
}),
'utf8'
)
await expect(store.get()).resolves.toEqual({
chatWorkflow: 'high-fidelity',
knowledgeWorkflow: 'high-fidelity',
localOcrModelId: 'pp-ocrv6-small',
maximumPages: 42,
pageTimeoutSeconds: 90
})
})
it('migrates version 1 cloud settings into local scenario modes', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
const {
ocrProvider: _ocrProvider,
...legacySettings
} = defaultDocumentParsingSettings
void _ocrProvider
await writeFile( await writeFile(
filePath, filePath,
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
...legacySettings, chatWorkflow: 'auto',
knowledgeWorkflow: 'complete-index',
pdfOcrMode: 'auto',
localOcrEnabled: false,
localOcrModelId: 'pp-ocrv6-tiny',
maximumPages: 100,
ocrConcurrency: 1,
pageTimeoutSeconds: 60,
chatCloudPermission: 'always', chatCloudPermission: 'always',
knowledgeCloudPermission: 'never' knowledgeCloudPermission: 'never'
}), }),
'utf8' 'utf8'
) )
await expect(store.get()).resolves.toEqual( await expect(store.get()).resolves.toEqual({
defaultDocumentParsingSettings ...defaultDocumentParsingSettings,
) chatWorkflow: 'fast-text',
knowledgeWorkflow: 'fast-index'
})
}) })
it('rejects incomplete or out-of-range settings', async () => { it('rejects incomplete or out-of-range settings', async () => {
+70 -19
View File
@@ -14,7 +14,7 @@ import {
type DocumentParsingSettings type DocumentParsingSettings
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
const CURRENT_SETTINGS_VERSION = 2 const CURRENT_SETTINGS_VERSION = 3
const storedDocumentParsingSettingsSchema = const storedDocumentParsingSettingsSchema =
documentParsingSettingsSchema documentParsingSettingsSchema
@@ -27,9 +27,32 @@ type StoredDocumentParsingSettings = z.infer<
typeof storedDocumentParsingSettingsSchema typeof storedDocumentParsingSettingsSchema
> >
const legacyDocumentParsingSettingsSchema = const legacyVersionTwoSettingsSchema = z
documentParsingSettingsSchema .object({
.omit({ ocrProvider: true }) version: z.literal(2),
chatWorkflow: z.enum(['auto', 'fast-text', 'high-fidelity']),
knowledgeWorkflow: z.enum([
'complete-index',
'fast-index',
'high-fidelity'
]),
pdfOcrMode: z.enum(['auto', 'always', 'disabled']),
ocrProvider: z.literal('local'),
localOcrEnabled: z.boolean(),
localOcrModelId: z
.string()
.min(1)
.max(96)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
maximumPages: z.number().int().min(1).max(500),
ocrConcurrency: z.number().int().min(1).max(4),
pageTimeoutSeconds: z.number().int().min(10).max(300)
})
.strict()
const legacyVersionOneSettingsSchema =
legacyVersionTwoSettingsSchema
.omit({ version: true, ocrProvider: true })
.extend({ .extend({
version: z.literal(1), version: z.literal(1),
chatCloudPermission: z.enum(['ask', 'always', 'never']), chatCloudPermission: z.enum(['ask', 'always', 'never']),
@@ -40,15 +63,39 @@ const legacyDocumentParsingSettingsSchema =
export const defaultDocumentParsingSettings: DocumentParsingSettings = { export const defaultDocumentParsingSettings: DocumentParsingSettings = {
chatWorkflow: 'auto', chatWorkflow: 'auto',
knowledgeWorkflow: 'complete-index', knowledgeWorkflow: 'complete-index',
pdfOcrMode: 'auto',
ocrProvider: 'local',
localOcrEnabled: true,
localOcrModelId: 'pp-ocrv6-tiny', localOcrModelId: 'pp-ocrv6-tiny',
maximumPages: 100, maximumPages: 100,
ocrConcurrency: 1,
pageTimeoutSeconds: 60 pageTimeoutSeconds: 60
} }
type LegacySettings = z.infer<
typeof legacyVersionTwoSettingsSchema
>
function migrateLegacySettings(
legacy: LegacySettings
): StoredDocumentParsingSettings {
const ocrDisabled =
!legacy.localOcrEnabled || legacy.pdfOcrMode === 'disabled'
const ocrAlways = legacy.pdfOcrMode === 'always'
return {
version: CURRENT_SETTINGS_VERSION,
chatWorkflow: ocrDisabled
? 'fast-text'
: legacy.chatWorkflow === 'auto' && ocrAlways
? 'high-fidelity'
: legacy.chatWorkflow,
knowledgeWorkflow: ocrDisabled
? 'fast-index'
: legacy.knowledgeWorkflow === 'complete-index' && ocrAlways
? 'high-fidelity'
: legacy.knowledgeWorkflow,
localOcrModelId: legacy.localOcrModelId,
maximumPages: legacy.maximumPages,
pageTimeoutSeconds: legacy.pageTimeoutSeconds
}
}
function isMissingFile(error: unknown): boolean { function isMissingFile(error: unknown): boolean {
return ( return (
error !== null && error !== null &&
@@ -99,23 +146,27 @@ export class DocumentParsingSettingsStore {
const result = const result =
storedDocumentParsingSettingsSchema.safeParse(parsed) storedDocumentParsingSettingsSchema.safeParse(parsed)
if (!result.success) { if (!result.success) {
const legacy = const versionTwo =
legacyDocumentParsingSettingsSchema.safeParse(parsed) legacyVersionTwoSettingsSchema.safeParse(parsed)
if (legacy.success) { if (versionTwo.success) {
this.settings = migrateLegacySettings(versionTwo.data)
return this.settings
}
const versionOne =
legacyVersionOneSettingsSchema.safeParse(parsed)
if (versionOne.success) {
const { const {
version: _version,
chatCloudPermission: _chatCloudPermission, chatCloudPermission: _chatCloudPermission,
knowledgeCloudPermission: _knowledgeCloudPermission, knowledgeCloudPermission: _knowledgeCloudPermission,
...settings ...legacy
} = legacy.data } = versionOne.data
void _version
void _chatCloudPermission void _chatCloudPermission
void _knowledgeCloudPermission void _knowledgeCloudPermission
this.settings = { this.settings = migrateLegacySettings({
version: CURRENT_SETTINGS_VERSION, ...legacy,
version: 2,
ocrProvider: 'local', ocrProvider: 'local',
...settings })
}
return this.settings return this.settings
} }
await this.isolateCorruptFile() await this.isolateCorruptFile()
+143
View File
@@ -784,6 +784,149 @@ describe('registerIpcHandlers model ZIP dialogs', () => {
}) })
}) })
describe('registerIpcHandlers document parsing', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
electronMocks.handlers.clear()
vi.clearAllMocks()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-document-parsing-')
)
temporaryDirectories.push(directory)
const diagnosticPath = join(directory, 'diagnostic.pdf')
const artifactPath = join(directory, 'artifact.pdf')
await writeFile(diagnosticPath, 'diagnostic')
await writeFile(artifactPath, 'artifact')
const diagnostic = {
fileName: 'diagnostic.pdf',
sourceFormat: 'PDF',
pageCount: 1,
ocrPageCount: 0,
characterCount: 4,
method: 'native',
durationMs: 1,
preview: 'text',
warnings: []
}
const documentParsingService = {
diagnose: vi.fn(async () => diagnostic),
parse: vi.fn(async () => ({
title: 'artifact',
sourceFormat: '.pdf',
content: 'parsed artifact',
sections: [
{
locator: '第 1 页',
content: 'parsed artifact',
method: 'native'
}
],
pageCount: 1,
warnings: []
}))
}
const createInlineArtifact = vi.fn((input) => input)
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
createInlineArtifact
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
documentParsingService as never
)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [diagnosticPath]
})
await expect(
electronMocks.handlers.get(
ipcChannels.documentParsingTest
)?.(event, { purpose: 'knowledge-index' })
).resolves.toBe(diagnostic)
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
'diagnostic.pdf',
expect.any(Buffer),
'knowledge-index'
)
await expect(
electronMocks.handlers.get(
ipcChannels.documentParsingTest
)?.(event, { purpose: 'diagnostic' })
).rejects.toThrow()
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [artifactPath]
})
await expect(
electronMocks.handlers.get(
ipcChannels.artifactsImportFiles
)?.(event)
).resolves.toEqual([
expect.objectContaining({
title: 'artifact.pdf',
content: '## 第 1 页\n\nparsed artifact'
})
])
expect(documentParsingService.parse).toHaveBeenCalledWith(
'artifact.pdf',
expect.any(Buffer),
'artifact-import'
)
await dispose()
})
})
describe('registerIpcHandlers connection tests', () => { describe('registerIpcHandlers connection tests', () => {
afterEach(() => { afterEach(() => {
electronMocks.handlers.clear() electronMocks.handlers.clear()
+14 -4
View File
@@ -102,7 +102,8 @@ import {
documentOcrModelActionInputSchema, documentOcrModelActionInputSchema,
documentOcrFailureSchema, documentOcrFailureSchema,
documentOcrResultSchema, documentOcrResultSchema,
documentParsingSettingsUpdateSchema documentParsingSettingsUpdateSchema,
documentParsingTestInputSchema
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
import { import {
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
@@ -2785,11 +2786,13 @@ export function registerIpcHandlers(
ipcMain.handle( ipcMain.handle(
ipcChannels.documentParsingTest, ipcChannels.documentParsingTest,
async (event) => { async (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!documentParsingService) { if (!documentParsingService) {
throw new Error('文档解析设置服务不可用') throw new Error('文档解析设置服务不可用')
} }
const { purpose } =
documentParsingTestInputSchema.parse(input)
const result = await dialog.showOpenDialog(window, { const result = await dialog.showOpenDialog(window, {
title: '选择测试文档', title: '选择测试文档',
properties: ['openFile'], properties: ['openFile'],
@@ -2814,7 +2817,8 @@ export function registerIpcHandlers(
} }
return documentParsingService.diagnose( return documentParsingService.diagnose(
basename(canonicalPath), basename(canonicalPath),
await readFile(canonicalPath) await readFile(canonicalPath),
purpose
) )
} catch (error) { } catch (error) {
if (error instanceof Error && !('code' in error)) { if (error instanceof Error && !('code' in error)) {
@@ -3436,7 +3440,13 @@ export function registerIpcHandlers(
) )
continue continue
} }
const parsed = await parseDocument(name, file) const parsed = documentParsingService
? await documentParsingService.parse(
name,
file,
'artifact-import'
)
: await parseDocument(name, file)
artifacts.push( artifacts.push(
assistantDatabase.createInlineArtifact({ assistantDatabase.createInlineArtifact({
projectId, projectId,
+5 -3
View File
@@ -85,7 +85,8 @@ import type {
DocumentOcrResult, DocumentOcrResult,
DocumentParsingDiagnostic, DocumentParsingDiagnostic,
DocumentParsingSettings, DocumentParsingSettings,
DocumentParsingSnapshot DocumentParsingSnapshot,
DocumentParsingTestPurpose
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import type { WeixinBindingSnapshot } from '../shared/weixin-channel-contracts' import type { WeixinBindingSnapshot } from '../shared/weixin-channel-contracts'
@@ -435,9 +436,10 @@ const desktopApi: DesktopApi = {
ipcChannels.documentParsingUpdate, ipcChannels.documentParsingUpdate,
input input
) as Promise<DocumentParsingSnapshot>, ) as Promise<DocumentParsingSnapshot>,
test: () => test: (purpose: DocumentParsingTestPurpose) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.documentParsingTest ipcChannels.documentParsingTest,
{ purpose }
) as Promise<DocumentParsingDiagnostic | undefined>, ) as Promise<DocumentParsingDiagnostic | undefined>,
installOcrModel: (modelId: string) => installOcrModel: (modelId: string) =>
ipcRenderer.invoke( ipcRenderer.invoke(
+13
View File
@@ -50,6 +50,19 @@ describe('sandboxed preload', () => {
expect(source).not.toContain('importOcrModel:') expect(source).not.toContain('importOcrModel:')
}) })
it('passes an explicit parsing scenario to document diagnostics', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain(
'test: (purpose: DocumentParsingTestPurpose)'
)
expect(source).toContain(
'ipcChannels.documentParsingTest,\n { purpose }'
)
})
it('exposes a removable attachment parsing progress listener', () => { it('exposes a removable attachment parsing progress listener', () => {
const source = readFileSync( const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'), join(process.cwd(), 'src', 'preload', 'index.ts'),
@@ -7,6 +7,7 @@ import {
} from '@testing-library/react' } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { import type {
InstalledDocumentOcrModel,
DocumentParsingSettings, DocumentParsingSettings,
DocumentParsingSnapshot DocumentParsingSnapshot
} from '../../shared/document-parsing-contracts' } from '../../shared/document-parsing-contracts'
@@ -16,12 +17,8 @@ import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection
const settings: DocumentParsingSettings = { const settings: DocumentParsingSettings = {
chatWorkflow: 'auto', chatWorkflow: 'auto',
knowledgeWorkflow: 'complete-index', knowledgeWorkflow: 'complete-index',
pdfOcrMode: 'auto',
ocrProvider: 'local',
localOcrEnabled: true,
localOcrModelId: 'pp-ocrv6-tiny', localOcrModelId: 'pp-ocrv6-tiny',
maximumPages: 100, maximumPages: 100,
ocrConcurrency: 1,
pageTimeoutSeconds: 60 pageTimeoutSeconds: 60
} }
@@ -139,35 +136,36 @@ const test = vi.fn(async () => ({
preview: '扫描件识别正文', preview: '扫描件识别正文',
warnings: [] warnings: []
})) }))
const installOcrModel = vi.fn(async () => ({ const installOcrModel =
...snapshot, vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({
status: { ...snapshot,
...snapshot.status, status: {
localOcr: { ...snapshot.status,
...snapshot.status.localOcr, localOcr: {
available: true, ...snapshot.status.localOcr,
verified: true, available: true,
detail: '模型已安装并校验' 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
}))
} }
] },
} 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 importOcrModelArchive = vi.fn(async () => snapshot)
const exportOcrModelArchive = vi.fn(async () => snapshot) const exportOcrModelArchive = vi.fn(async () => snapshot)
const openOcrModelRepository = vi.fn(async () => undefined) const openOcrModelRepository = vi.fn(async () => undefined)
@@ -212,17 +210,7 @@ describe('DocumentParsingSettingsSection', () => {
expect(screen.getByText('质量:基础')).toBeInTheDocument() expect(screen.getByText('质量:基础')).toBeInTheDocument()
expect(screen.getByText('速度:快')).toBeInTheDocument() expect(screen.getByText('速度:快')).toBeInTheDocument()
expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument() expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument()
expect( expect(screen.queryByRole('switch')).not.toBeInTheDocument()
screen.getByRole('switch', { name: / OCR/u })
).toBeChecked()
expect(
screen.getByRole('button', { name: '本地模型' })
).toHaveAttribute('aria-pressed', 'true')
expect(
screen.getByRole('button', {
name: '远程服务(即将支持)'
})
).toBeDisabled()
expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument() expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument()
expect( expect(
screen.queryByText('模型详情与手动导入') screen.queryByText('模型详情与手动导入')
@@ -237,12 +225,15 @@ describe('DocumentParsingSettingsSection', () => {
) )
expect(openOcrModelRepository).toHaveBeenCalledWith('pp-ocrv6-tiny') expect(openOcrModelRepository).toHaveBeenCalledWith('pp-ocrv6-tiny')
fireEvent.change(screen.getByLabelText('聊天件'), { fireEvent.change(screen.getByLabelText('聊天与成果文件'), {
target: { value: 'fast-text' } target: { value: 'fast-text' }
}) })
fireEvent.click( expect(
screen.getByRole('button', { name: '保存设置' }) screen.getByRole('button', {
) name: '测试聊天与成果模式'
})
).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => await waitFor(() =>
expect(update).toHaveBeenCalledWith( 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 () => { it('imports and exports verified OCR model ZIP archives', async () => {
const onNotify = vi.fn() const onNotify = vi.fn()
render( render(
@@ -370,7 +414,9 @@ describe('DocumentParsingSettingsSection', () => {
await screen.findByText('PP-OCRv6 Tiny') await screen.findByText('PP-OCRv6 Tiny')
fireEvent.click( fireEvent.click(
screen.getByRole('button', { name: '测试解析' }) screen.getByRole('button', {
name: '测试聊天与成果模式'
})
) )
expect( expect(
@@ -378,6 +424,7 @@ describe('DocumentParsingSettingsSection', () => {
name: '解析测试结果' name: '解析测试结果'
}) })
).toHaveTextContent('扫描件识别正文') ).toHaveTextContent('扫描件识别正文')
expect(test).toHaveBeenCalledOnce() expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled()
}) })
}) })
@@ -25,11 +25,11 @@ import type {
DocumentOcrModelCatalogEntry, DocumentOcrModelCatalogEntry,
DocumentOcrModelOperation, DocumentOcrModelOperation,
DocumentParsingSettings, DocumentParsingSettings,
DocumentParsingSnapshot DocumentParsingSnapshot,
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts' } from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives' import { SettingsCategoryHeader } from './SettingsPrimitives'
import { SegmentedControl } from './WorkspacePrimitives'
type DocumentParsingSettingsSectionProps = { type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -211,7 +211,8 @@ export function DocumentParsingSettingsSection({
const [draft, setDraft] = useState<DocumentParsingSettings>() const [draft, setDraft] = useState<DocumentParsingSettings>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false) const [testingPurpose, setTestingPurpose] =
useState<DocumentParsingTestPurpose>()
const [busyModelId, setBusyModelId] = useState<string>() const [busyModelId, setBusyModelId] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState<string>() const [confirmingRemove, setConfirmingRemove] = useState<string>()
const [diagnostic, setDiagnostic] = const [diagnostic, setDiagnostic] =
@@ -283,9 +284,7 @@ export function DocumentParsingSettingsSection({
) )
} }
const save = async ( const save = async (): Promise<DocumentParsingSnapshot | undefined> => {
notify = true
): Promise<DocumentParsingSnapshot | undefined> => {
const api = window.goodbuddy.documentParsing const api = window.goodbuddy.documentParsing
if (!api || !draft) { if (!api || !draft) {
return undefined return undefined
@@ -296,13 +295,11 @@ export function DocumentParsingSettingsSection({
const next = await api.update(draft) const next = await api.update(draft)
setSnapshot(next) setSnapshot(next)
setDraft(next.settings) setDraft(next.settings)
if (notify) { onNotify?.({
onNotify?.({ tone: 'success',
tone: 'success', message: t('notifications.documentParsingSaved'),
message: t('notifications.documentParsingSaved'), dedupeKey: 'document-parsing-saved'
dedupeKey: 'document-parsing-saved' })
})
}
return next return next
} catch (reason) { } catch (reason) {
setError( 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 const api = window.goodbuddy.documentParsing
if (!api) { if (!api) {
setError(t('errors.documentParsingUnavailable')) setError(t('errors.documentParsingUnavailable'))
return return
} }
setTesting(true) setTestingPurpose(purpose)
setError(undefined) setError(undefined)
try { try {
if (!(await save(false))) { const result = await api.test(purpose)
return
}
const result = await api.test()
if (result) { if (result) {
setDiagnostic(result) setDiagnostic(result)
onNotify?.({ onNotify?.({
@@ -391,7 +387,7 @@ export function DocumentParsingSettingsSection({
errorMessage(reason, t('errors.testDocumentParsing')) errorMessage(reason, t('errors.testDocumentParsing'))
) )
} finally { } finally {
setTesting(false) setTestingPurpose(undefined)
} }
} }
@@ -415,6 +411,10 @@ export function DocumentParsingSettingsSection({
const model = snapshot.ocrModels.catalog.find( const model = snapshot.ocrModels.catalog.find(
(entry) => entry.id === draft.localOcrModelId (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( const installedModel = snapshot.ocrModels.installed.find(
(entry) => entry.id === draft.localOcrModelId (entry) => entry.id === draft.localOcrModelId
) )
@@ -426,38 +426,41 @@ export function DocumentParsingSettingsSection({
: undefined : undefined
const pendingModelSelection = const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId 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 ( return (
<> <>
<SettingsCategoryHeader <SettingsCategoryHeader
actions={ actions={
<> <button
<button className="primary-button"
className="secondary-button" disabled={
disabled={saving || testing} saving || testing || !settingsDirty || invalidPendingModel
onClick={() => void testParsing()} }
type="button" onClick={() => void save()}
> type="button"
<FileSearch aria-hidden="true" size={14} /> >
{testing {saving
? t('actions.testingParsing') ? t('actions.saving')
: t('actions.testParsing')} : t('actions.saveSettings')}
</button> </button>
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
{saving
? t('actions.saving')
: t('actions.saveSettings')}
</button>
</>
} }
category="document-parsing" category="document-parsing"
error={error} error={error}
/> />
{settingsDirty && (
<p
className="settings-notice"
id="document-parsing-unsaved-notice"
>
{t('documentParsing.workflows.unsavedNotice')}
</p>
)}
<section <section
aria-labelledby="document-parsing-status-title" aria-labelledby="document-parsing-status-title"
@@ -483,9 +486,13 @@ export function DocumentParsingSettingsSection({
detail={t( detail={t(
snapshot.status.localOcr.available snapshot.status.localOcr.available
? 'documentParsing.status.ocrReady' ? '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 <StatusRow
available={snapshot.status.conversionAvailable} available={snapshot.status.conversionAvailable}
@@ -514,10 +521,13 @@ export function DocumentParsingSettingsSection({
</div> </div>
</div> </div>
<div className="document-parsing-grid"> <div className="document-parsing-grid">
<label className="field"> <div className="field">
<span>{t('documentParsing.workflows.chat')}</span> <label htmlFor="document-parsing-chat-workflow">
{t('documentParsing.workflows.chat')}
</label>
<select <select
aria-label={t('documentParsing.workflows.chat')} aria-label={t('documentParsing.workflows.chat')}
id="document-parsing-chat-workflow"
onChange={(event) => onChange={(event) =>
updateDraft( updateDraft(
'chatWorkflow', 'chatWorkflow',
@@ -540,13 +550,36 @@ export function DocumentParsingSettingsSection({
</option> </option>
</select> </select>
<small> <small>
{t('documentParsing.workflows.chatDescription')} {t(
`documentParsing.workflows.chatDescriptions.${draft.chatWorkflow}`
)}
</small> </small>
</label> <button
<label className="field"> aria-describedby={
<span>{t('documentParsing.workflows.knowledge')}</span> 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 <select
aria-label={t('documentParsing.workflows.knowledge')} aria-label={t('documentParsing.workflows.knowledge')}
id="document-parsing-knowledge-workflow"
onChange={(event) => onChange={(event) =>
updateDraft( updateDraft(
'knowledgeWorkflow', 'knowledgeWorkflow',
@@ -573,9 +606,29 @@ export function DocumentParsingSettingsSection({
</option> </option>
</select> </select>
<small> <small>
{t('documentParsing.workflows.knowledgeDescription')} {t(
`documentParsing.workflows.knowledgeDescriptions.${draft.knowledgeWorkflow}`
)}
</small> </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> </div>
</section> </section>
@@ -604,40 +657,6 @@ export function DocumentParsingSettingsSection({
</button> </button>
</div> </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"> <label className="field document-ocr-model-selector">
<span>{t('documentParsing.ocr.modelSelector')}</span> <span>{t('documentParsing.ocr.modelSelector')}</span>
<select <select
@@ -647,6 +666,12 @@ export function DocumentParsingSettingsSection({
} }
value={draft.localOcrModelId} value={draft.localOcrModelId}
> >
{!selectedModelInCatalog && (
<option value={draft.localOcrModelId}>
{draft.localOcrModelId} ·{' '}
{t('documentParsing.ocr.unavailableOption')}
</option>
)}
{snapshot.ocrModels.catalog.map((entry) => { {snapshot.ocrModels.catalog.map((entry) => {
const installed = snapshot.ocrModels.installed.some( const installed = snapshot.ocrModels.installed.some(
(candidate) => candidate.id === entry.id (candidate) => candidate.id === entry.id
@@ -662,7 +687,9 @@ export function DocumentParsingSettingsSection({
})} })}
</select> </select>
<small> <small>
{pendingModelSelection {invalidPendingModel
? t('documentParsing.ocr.installBeforeSelecting')
: pendingModelSelection
? t('documentParsing.ocr.pendingSelection') ? t('documentParsing.ocr.pendingSelection')
: t('documentParsing.ocr.modelSelectorDescription')} : t('documentParsing.ocr.modelSelectorDescription')}
</small> </small>
@@ -835,11 +862,20 @@ export function DocumentParsingSettingsSection({
onClick={() => onClick={() =>
void runModelOperation( void runModelOperation(
model.id, model.id,
() => async () => {
window.goodbuddy.documentParsing! const installed =
.installOcrModel(model.id), await window.goodbuddy.documentParsing!
.installOcrModel(model.id)
if (!pendingModelSelection) {
return installed
}
return window.goodbuddy.documentParsing!
.update(draft)
},
t( t(
'documentParsing.ocr.notifications.installed', pendingModelSelection
? 'documentParsing.ocr.notifications.installedAndSelected'
: 'documentParsing.ocr.notifications.installed',
{ name: model.displayName } { name: model.displayName }
) )
) )
@@ -847,7 +883,9 @@ export function DocumentParsingSettingsSection({
type="button" type="button"
> >
<Download aria-hidden="true" size={13} /> <Download aria-hidden="true" size={13} />
{t('documentParsing.ocr.download')} {pendingModelSelection
? t('documentParsing.ocr.downloadAndSelect')
: t('documentParsing.ocr.download')}
</button> </button>
<button <button
aria-label={t( aria-label={t(
@@ -859,11 +897,20 @@ export function DocumentParsingSettingsSection({
onClick={() => onClick={() =>
void runModelOperation( void runModelOperation(
model.id, model.id,
() => async () => {
window.goodbuddy.documentParsing! const imported =
.importOcrModelArchive(model.id), await window.goodbuddy.documentParsing!
.importOcrModelArchive(model.id)
if (!imported || !pendingModelSelection) {
return imported
}
return window.goodbuddy.documentParsing!
.update(draft)
},
t( t(
'documentParsing.ocr.notifications.importedZip', pendingModelSelection
? 'documentParsing.ocr.notifications.importedAndSelected'
: 'documentParsing.ocr.notifications.importedZip',
{ name: model.displayName } { name: model.displayName }
) )
) )
@@ -904,55 +951,17 @@ export function DocumentParsingSettingsSection({
</article> </article>
) : ( ) : (
<p className="settings-warning"> <p className="settings-warning">
{t('documentParsing.ocr.catalogUnavailable')} {t(
snapshot.ocrModels.catalog.length === 0
? 'documentParsing.ocr.catalogUnavailable'
: 'documentParsing.ocr.selectedModelUnavailable'
)}
</p> </p>
)} )}
<div className="document-ocr-settings__options"> <p className="settings-notice">
<label className="toggle-row"> {t('documentParsing.ocr.privacyNotice')}
<input </p>
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>
</>
)}
</section> </section>
<details className="settings-section"> <details className="settings-section">
@@ -973,21 +982,6 @@ export function DocumentParsingSettingsSection({
value={draft.maximumPages} value={draft.maximumPages}
/> />
</label> </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"> <label className="field">
<span>{t('documentParsing.advanced.timeout')}</span> <span>{t('documentParsing.advanced.timeout')}</span>
<input <input
@@ -1004,7 +998,7 @@ export function DocumentParsingSettingsSection({
/> />
</label> </label>
</div> </div>
<small>{t('documentParsing.advanced.concurrencyHint')}</small> <small>{t('documentParsing.advanced.description')}</small>
</details> </details>
{diagnostic && ( {diagnostic && (
+30 -14
View File
@@ -6,6 +6,7 @@ import type {
type WorkerOutput = type WorkerOutput =
| { type: 'ready' } | { type: 'ready' }
| { type: 'progress'; requestId: string; pageNumber?: number }
| { type: 'result'; result: DocumentOcrResult } | { type: 'result'; result: DocumentOcrResult }
| { type: 'error'; requestId?: string; error: string } | { type: 'error'; requestId?: string; error: string }
@@ -13,6 +14,7 @@ type PendingWorkerRequest = {
resolve: (result: DocumentOcrResult) => void resolve: (result: DocumentOcrResult) => void
reject: (error: Error) => void reject: (error: Error) => void
timer: number timer: number
timeoutMs: number
} }
let worker: Worker | undefined let worker: Worker | undefined
@@ -46,6 +48,18 @@ function terminateWorker(error: Error): void {
pending.clear() 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> { async function ensureWorker(modelId: string): Promise<Worker> {
if (worker && workerReady && workerModelId === modelId) { if (worker && workerReady && workerModelId === modelId) {
await workerReady await workerReady
@@ -74,6 +88,13 @@ async function ensureWorker(modelId: string): Promise<Worker> {
resolveWorkerReady?.() resolveWorkerReady?.()
return return
} }
if (output.type === 'progress') {
const request = pending.get(output.requestId)
if (request) {
armPageTimeout(output.requestId, request)
}
return
}
if (output.type === 'error' && !output.requestId) { if (output.type === 'error' && !output.requestId) {
rejectWorkerReady?.(new Error(output.error)) rejectWorkerReady?.(new Error(output.error))
return return
@@ -134,21 +155,16 @@ async function recognize(
if (cancelledRequestIds.has(request.requestId)) { if (cancelledRequestIds.has(request.requestId)) {
throw new Error('本地 OCR 解析已取消') throw new Error('本地 OCR 解析已取消')
} }
const pageCount = request.pageNumbers?.length ?? request.maximumPages const timeoutMs = request.pageTimeoutSeconds * 1_000
const timeoutMs = Math.min(
10 * 60 * 1_000,
Math.max(
request.pageTimeoutSeconds * 1_000,
request.pageTimeoutSeconds * pageCount * 1_000
)
)
return new Promise<DocumentOcrResult>((resolve, reject) => { return new Promise<DocumentOcrResult>((resolve, reject) => {
const timer = window.setTimeout(() => { const workerRequest = {
pending.delete(request.requestId) resolve,
terminateWorker(new Error('本地 OCR 解析超时')) reject,
reject(new Error('本地 OCR 解析超时')) timer: 0,
}, timeoutMs) timeoutMs
pending.set(request.requestId, { resolve, reject, timer }) }
pending.set(request.requestId, workerRequest)
armPageTimeout(request.requestId, workerRequest)
activeWorker.postMessage( activeWorker.postMessage(
{ type: 'recognize', request }, { type: 'recognize', request },
[request.data] [request.data]
+25 -3
View File
@@ -26,6 +26,7 @@ type WorkerInput = InitializeMessage | RecognizeMessage
type WorkerOutput = type WorkerOutput =
| { type: 'ready' } | { type: 'ready' }
| { type: 'progress'; requestId: string; pageNumber?: number }
| { type: 'result'; result: DocumentOcrResult } | { type: 'result'; result: DocumentOcrResult }
| { type: 'error'; requestId?: string; error: string } | { type: 'error'; requestId?: string; error: string }
@@ -141,6 +142,10 @@ async function renderPdfPage(
async function recognizePdf( async function recognizePdf(
request: DocumentOcrRequest request: DocumentOcrRequest
): Promise<DocumentOcrResult> { ): Promise<DocumentOcrResult> {
worker.postMessage({
type: 'progress',
requestId: request.requestId
} satisfies WorkerOutput)
const pdfjs = await import('pdfjs-dist') const pdfjs = await import('pdfjs-dist')
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
const loadingTask = pdfjs.getDocument( const loadingTask = pdfjs.getDocument(
@@ -150,14 +155,21 @@ async function recognizePdf(
const selectedPages = new Set( const selectedPages = new Set(
request.pageNumbers ?? request.pageNumbers ??
Array.from( Array.from(
{ length: Math.min(document.numPages, request.maximumPages) }, { length: document.numPages },
(_, index) => index + 1 (_, 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() await loadingTask.destroy()
throw new Error( throw new Error(
`PDF ${document.numPages} 页,超过 ${request.maximumPages} 页限制` `PDF ${selectedPages.size}需要 OCR,超过 ${request.maximumPages} 页限制`
) )
} }
const sections: DocumentOcrResult['sections'] = [] const sections: DocumentOcrResult['sections'] = []
@@ -171,6 +183,11 @@ async function recognizePdf(
if (!selectedPages.has(pageNumber)) { if (!selectedPages.has(pageNumber)) {
continue continue
} }
worker.postMessage({
type: 'progress',
requestId: request.requestId,
pageNumber
} satisfies WorkerOutput)
const page = await document.getPage(pageNumber) const page = await document.getPage(pageNumber)
try { try {
const section = await recognizeImage( const section = await recognizeImage(
@@ -201,6 +218,11 @@ async function recognize(request: DocumentOcrRequest): Promise<DocumentOcrResult
if (request.mimeType === 'application/pdf') { if (request.mimeType === 'application/pdf') {
return recognizePdf(request) return recognizePdf(request)
} }
worker.postMessage({
type: 'progress',
requestId: request.requestId,
pageNumber: 1
} satisfies WorkerOutput)
const section = await recognizeImage(request.data, '图片') const section = await recognizeImage(request.data, '图片')
return { return {
requestId: request.requestId, requestId: request.requestId,
+45 -40
View File
@@ -83,7 +83,6 @@ export const settings = {
saveAndTestRuntime: 'Save and test {{runtime}}', saveAndTestRuntime: 'Save and test {{runtime}}',
saving: 'Saving…', saving: 'Saving…',
saveSettings: 'Save settings', saveSettings: 'Save settings',
testParsing: 'Test parsing',
testingParsing: 'Parsing…', testingParsing: 'Parsing…',
select: 'Select', select: 'Select',
selectFile: 'Select file', selectFile: 'Select file',
@@ -238,52 +237,55 @@ export const settings = {
conversionUnavailable: conversionUnavailable:
'Not implemented yet; DOC, XLS, and PPT are currently unavailable', 'Not implemented yet; DOC, XLS, and PPT are currently unavailable',
localOcr: 'Local OCR', localOcr: 'Local OCR',
localOcrModel: 'Current OCR: {{name}}',
ocrReady: ocrReady:
'The model is installed, SHA-256 verified, and available offline', 'The model is installed, SHA-256 verified, and available offline',
ocrUnavailable: ocrUnavailable:
'The model is not installed or failed verification. Download it from ModelScope.', 'The model is not installed or failed verification. Download it from ModelScope.',
partialNotice: 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: { workflows: {
title: 'Usage scenarios', title: 'PDF parsing modes',
description: description:
'Choose different parsing depth for chat attachments and knowledge imports', 'Choose how each scenario handles PDF text layers and scanned pages',
chat: 'Chat attachments', chat: 'Chat and artifact files',
chatDescription:
'Controls parsing before an attachment is added to the current request',
knowledge: 'Knowledge imports', knowledge: 'Knowledge imports',
knowledgeDescription: testChat: 'Test chat and artifact mode',
'Controls parsing before chunking, indexing, and source location', testKnowledge: 'Test knowledge mode',
unsavedNotice:
'There are unsaved changes. Save them before testing the active mode.',
chatOptions: { chatOptions: {
auto: 'Automatic parsing (recommended)', auto: 'Automatic recognition (recommended)',
fastText: 'Fast text', fastText: 'Text layer only',
highFidelity: 'High-fidelity parsing' 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: { knowledgeOptions: {
completeIndex: 'Complete indexing (recommended)', completeIndex: 'Automatic recognition (recommended)',
fastIndex: 'Fast indexing', fastIndex: 'Text layer only',
highFidelity: 'High-fidelity indexing' 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: { ocr: {
title: 'OCR recognition', title: 'OCR recognition',
description: description:
'Install a local model on demand to recognize scanned PDFs on this device', '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', modelSelector: 'Current OCR model',
modelSelectorDescription: modelSelectorDescription:
'This saved model is used for chat attachments and knowledge imports.', '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.', 'This model selection is not active yet. Save settings to switch.',
installedOption: 'Installed', installedOption: 'Installed',
downloadableOption: 'Available to download', downloadableOption: 'Available to download',
unavailableOption: 'Unavailable in this version',
openModelsDirectory: 'Open model folder', openModelsDirectory: 'Open model folder',
storagePrefix: 'Models are installed on demand in', storagePrefix: 'Models are installed on demand in',
storageSuffix: storageSuffix:
@@ -314,6 +317,7 @@ export const settings = {
}, },
installed: 'Installed and verified', installed: 'Installed and verified',
download: 'Download', download: 'Download',
downloadAndSelect: 'Download and enable',
importZip: 'Import ZIP', importZip: 'Import ZIP',
exportZip: 'Export ZIP', exportZip: 'Export ZIP',
delete: 'Delete', delete: 'Delete',
@@ -322,14 +326,12 @@ export const settings = {
openRepository: 'Open ModelScope', openRepository: 'Open ModelScope',
catalogUnavailable: catalogUnavailable:
'No OCR model catalog is available in this version.', 'No OCR model catalog is available in this version.',
mode: 'PDF OCR strategy', selectedModelUnavailable:
modes: { 'The saved OCR model is unavailable in this version. Select and install another model above.',
auto: 'Automatic; recognize only pages without useful text', installBeforeSelecting:
always: 'Always recognize every page', 'Download this model first. It will become the current model after installation.',
disabled: 'Use only the PDF text layer' privacyNotice:
}, 'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
modelLicense:
'The model uses Apache License 2.0 and is SHA-256 verified before loading.',
operations: { operations: {
preparing: 'Preparing model files', preparing: 'Preparing model files',
downloading: 'Downloading from ModelScope', downloading: 'Downloading from ModelScope',
@@ -347,7 +349,11 @@ export const settings = {
}, },
notifications: { notifications: {
installed: '{{name}} installed', installed: '{{name}} installed',
installedAndSelected:
'{{name}} installed and selected as the current model',
importedZip: '{{name}} imported from ZIP', importedZip: '{{name}} imported from ZIP',
importedAndSelected:
'{{name}} imported and selected as the current model',
exportedZip: '{{name}} exported as ZIP', exportedZip: '{{name}} exported as ZIP',
removed: 'OCR model deleted' removed: 'OCR model deleted'
} }
@@ -355,10 +361,9 @@ export const settings = {
advanced: { advanced: {
title: 'Advanced parsing settings', title: 'Advanced parsing settings',
maximumPages: 'Maximum OCR pages per document', maximumPages: 'Maximum OCR pages per document',
concurrency: 'OCR concurrency',
timeout: 'OCR time budget per page (seconds)', timeout: 'OCR time budget per page (seconds)',
concurrencyHint: description:
'The WASM baseline currently processes pages serially; this value is reserved for batching and hardware acceleration.' 'The page limit counts only pages actually sent to OCR. Parsing stops if one page exceeds its time budget.'
}, },
diagnostic: { diagnostic: {
title: 'Parsing test result', title: 'Parsing test result',
+40 -37
View File
@@ -74,7 +74,6 @@ export const settings = {
saveAndTestRuntime: '保存并测试 {{runtime}}', saveAndTestRuntime: '保存并测试 {{runtime}}',
saving: '保存中…', saving: '保存中…',
saveSettings: '保存设置', saveSettings: '保存设置',
testParsing: '测试解析',
testingParsing: '正在解析…', testingParsing: '正在解析…',
select: '选择', select: '选择',
selectFile: '选择文件', selectFile: '选择文件',
@@ -217,50 +216,55 @@ export const settings = {
conversion: '旧版 Office 转换', conversion: '旧版 Office 转换',
conversionUnavailable: '尚未实现,DOC、XLS、PPT 暂不可用', conversionUnavailable: '尚未实现,DOC、XLS、PPT 暂不可用',
localOcr: '本地 OCR', localOcr: '本地 OCR',
localOcrModel: '当前 OCR{{name}}',
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用', ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载', ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
partialNotice: partialNotice:
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 使用本地 OCR。' '基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
}, },
workflows: { workflows: {
title: '使用场景', title: 'PDF 解析模式',
description: '为聊天附件和知识库选择不同的解析深度', description: '直接选择各场景处理 PDF 文本层与扫描页面的方式',
chat: '聊天件', chat: '聊天与成果文件',
chatDescription: '控制附件加入当前请求前的解析方式',
knowledge: '知识库导入', knowledge: '知识库导入',
knowledgeDescription: '控制文档分块、索引和来源定位前的解析方式', testChat: '测试聊天与成果模式',
testKnowledge: '测试知识库模式',
unsavedNotice: '当前有未保存修改;保存后可测试实际生效的模式。',
chatOptions: { chatOptions: {
auto: '自动解析(推荐)', auto: '自动识别(推荐)',
fastText: '快速文本', fastText: '仅使用文本',
highFidelity: '高保真解析' highFidelity: '全页 OCR'
},
chatDescriptions: {
auto:
'聊天附件和成果 PDF 优先使用文本层,仅对无有效文本的页面使用 OCR。',
fastText:
'聊天附件和成果 PDF 仅使用文本层,不运行 OCR;扫描件可能无法读取。',
highFidelity: '对 PDF 的每一页运行 OCR,速度较慢。'
}, },
knowledgeOptions: { knowledgeOptions: {
completeIndex: '完整索引(推荐)', completeIndex: '自动识别(推荐)',
fastIndex: '快速索引', fastIndex: '仅使用文本层',
highFidelity: '高保真索引' highFidelity: '全页 OCR'
},
knowledgeDescriptions: {
'complete-index':
'优先使用 PDF 文本层,仅对无有效文本的页面使用 OCR。',
'fast-index':
'仅使用 PDF 文本层,不运行 OCR;扫描页面不会进入索引。',
'high-fidelity':
'对 PDF 的每一页运行 OCR 后再分块和建立索引,速度较慢。'
} }
}, },
ocr: { ocr: {
title: 'OCR 识别', title: 'OCR 识别',
description: '按需安装本地模型,在设备上识别扫描 PDF', description: '按需安装本地模型,在设备上识别扫描 PDF',
enabled: '启用本地 OCR',
enabledDescription:
'模型安装后仅在本机通过 ONNX Runtime WebAssembly 运行,识别时不会上传文档。',
model: '本地模型',
runtime: '运行时',
provider: {
title: 'OCR 来源',
description: '本地模型与远程服务二选一,切换后保存设置生效。',
local: '本地模型',
remote: '远程服务(即将支持)',
remoteDescription:
'远程服务将支持 MinerU、PaddleOCR-VL 等接口,当前版本暂不可选。'
},
modelSelector: '当前 OCR 模型', modelSelector: '当前 OCR 模型',
modelSelectorDescription: '选择已保存,聊天附件和知识库将使用此模型。', modelSelectorDescription: '选择已保存,聊天附件和知识库将使用此模型。',
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。', pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
installedOption: '已安装', installedOption: '已安装',
downloadableOption: '可下载', downloadableOption: '可下载',
unavailableOption: '当前版本不可用',
openModelsDirectory: '打开模型目录', openModelsDirectory: '打开模型目录',
storagePrefix: '模型按需安装到', storagePrefix: '模型按需安装到',
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。', storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
@@ -283,6 +287,7 @@ export const settings = {
}, },
installed: '已安装并校验', installed: '已安装并校验',
download: '下载', download: '下载',
downloadAndSelect: '下载并启用',
importZip: '导入 ZIP', importZip: '导入 ZIP',
exportZip: '导出 ZIP', exportZip: '导出 ZIP',
delete: '删除', delete: '删除',
@@ -290,14 +295,11 @@ export const settings = {
cancel: '取消', cancel: '取消',
openRepository: '打开 ModelScope', openRepository: '打开 ModelScope',
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。', catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
mode: 'PDF OCR 策略', selectedModelUnavailable:
modes: { '已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
auto: '自动,仅识别无有效文本的页面', installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。',
always: '始终识别所有页面', privacyNotice:
disabled: '仅使用 PDF 文本层' 'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
},
modelLicense:
'模型采用 Apache License 2.0,并在加载前校验 SHA-256。',
operations: { operations: {
preparing: '正在准备模型文件', preparing: '正在准备模型文件',
downloading: '正在从 ModelScope 下载', downloading: '正在从 ModelScope 下载',
@@ -315,7 +317,9 @@ export const settings = {
}, },
notifications: { notifications: {
installed: '{{name}} 已安装', installed: '{{name}} 已安装',
installedAndSelected: '{{name}} 已安装并设为当前模型',
importedZip: '{{name}} 已从 ZIP 导入', importedZip: '{{name}} 已从 ZIP 导入',
importedAndSelected: '{{name}} 已导入并设为当前模型',
exportedZip: '{{name}} 已导出为 ZIP', exportedZip: '{{name}} 已导出为 ZIP',
removed: 'OCR 模型已删除' removed: 'OCR 模型已删除'
} }
@@ -323,10 +327,9 @@ export const settings = {
advanced: { advanced: {
title: '高级解析设置', title: '高级解析设置',
maximumPages: '单文档最大 OCR 页数', maximumPages: '单文档最大 OCR 页数',
concurrency: 'OCR 并发数',
timeout: '每页 OCR 时间预算(秒)', timeout: '每页 OCR 时间预算(秒)',
concurrencyHint: description:
'当前 WASM 基线按页串行执行;该值为后续批处理和硬件加速保留。' '页数限制只计算实际进入 OCR 的页面;单页超过时间预算时会终止本次解析。'
}, },
diagnostic: { diagnostic: {
title: '解析测试结果', title: '解析测试结果',
+9 -76
View File
@@ -5530,6 +5530,11 @@ details.settings-section > :not(summary) + :not(summary) {
line-height: 1.5; line-height: 1.5;
} }
.document-parsing-workflows .field > label {
font-size: var(--font-caption);
font-weight: 650;
}
.document-parsing-workflows .field { .document-parsing-workflows .field {
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
@@ -5559,37 +5564,6 @@ details.settings-section > :not(summary) + :not(summary) {
overflow-wrap: anywhere; 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 { .document-ocr-model-selector {
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
@@ -5724,49 +5698,9 @@ details.settings-section > :not(summary) + :not(summary) {
font-size: var(--font-caption); font-size: var(--font-caption);
} }
.document-ocr-settings__options { .document-parsing-workflow-test {
display: grid; width: fit-content;
align-items: start; margin-top: auto;
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-diagnostic-backdrop { .document-parsing-diagnostic-backdrop {
@@ -5866,8 +5800,7 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 720px) { @media (max-width: 720px) {
.document-parsing-status__list, .document-parsing-status__list,
.document-parsing-grid, .document-parsing-grid,
.document-parsing-diagnostic dl, .document-parsing-diagnostic dl {
.document-ocr-settings__options {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
+5 -2
View File
@@ -83,7 +83,8 @@ import type {
DocumentOcrResult, DocumentOcrResult,
DocumentParsingDiagnostic, DocumentParsingDiagnostic,
DocumentParsingSettings, DocumentParsingSettings,
DocumentParsingSnapshot DocumentParsingSnapshot,
DocumentParsingTestPurpose
} from './document-parsing-contracts' } from './document-parsing-contracts'
import type { import type {
KnowledgeChunkDeleteInput, KnowledgeChunkDeleteInput,
@@ -1159,7 +1160,9 @@ export type DesktopApi = {
update: ( update: (
input: DocumentParsingSettings input: DocumentParsingSettings
) => Promise<DocumentParsingSnapshot> ) => Promise<DocumentParsingSnapshot>
test: () => Promise<DocumentParsingDiagnostic | undefined> test: (
purpose: DocumentParsingTestPurpose
) => Promise<DocumentParsingDiagnostic | undefined>
installOcrModel: ( installOcrModel: (
modelId: string modelId: string
) => Promise<DocumentParsingSnapshot> ) => Promise<DocumentParsingSnapshot>
+31 -13
View File
@@ -3,9 +3,15 @@ import { z } from 'zod'
export const documentParsingPurposeSchema = z.enum([ export const documentParsingPurposeSchema = z.enum([
'chat-attachment', 'chat-attachment',
'knowledge-index', 'knowledge-index',
'artifact-import',
'diagnostic' 'diagnostic'
]) ])
export const documentParsingTestPurposeSchema = z.enum([
'chat-attachment',
'knowledge-index'
])
export const chatDocumentWorkflowSchema = z.enum([ export const chatDocumentWorkflowSchema = z.enum([
'auto', 'auto',
'fast-text', 'fast-text',
@@ -18,14 +24,6 @@ export const knowledgeDocumentWorkflowSchema = z.enum([
'high-fidelity' 'high-fidelity'
]) ])
export const pdfOcrModeSchema = z.enum([
'auto',
'always',
'disabled'
])
export const documentOcrProviderSchema = z.literal('local')
export const localOcrModelIdSchema = z export const localOcrModelIdSchema = z
.string() .string()
.min(1) .min(1)
@@ -157,12 +155,8 @@ export const documentParsingSettingsSchema = z
.object({ .object({
chatWorkflow: chatDocumentWorkflowSchema, chatWorkflow: chatDocumentWorkflowSchema,
knowledgeWorkflow: knowledgeDocumentWorkflowSchema, knowledgeWorkflow: knowledgeDocumentWorkflowSchema,
pdfOcrMode: pdfOcrModeSchema,
ocrProvider: documentOcrProviderSchema,
localOcrEnabled: z.boolean(),
localOcrModelId: localOcrModelIdSchema, localOcrModelId: localOcrModelIdSchema,
maximumPages: z.number().int().min(1).max(500), maximumPages: z.number().int().min(1).max(500),
ocrConcurrency: z.number().int().min(1).max(4),
pageTimeoutSeconds: z.number().int().min(10).max(300) pageTimeoutSeconds: z.number().int().min(10).max(300)
}) })
.strict() .strict()
@@ -199,7 +193,7 @@ export const documentParsingSnapshotSchema = z
export const documentParsingTestInputSchema = z export const documentParsingTestInputSchema = z
.object({ .object({
purpose: documentParsingPurposeSchema.default('diagnostic') purpose: documentParsingTestPurposeSchema
}) })
.strict() .strict()
@@ -252,6 +246,27 @@ export const documentOcrRequestSchema = z
pageTimeoutSeconds: z.number().int().min(10).max(300) pageTimeoutSeconds: z.number().int().min(10).max(300)
}) })
.strict() .strict()
.superRefine((request, context) => {
if (!request.pageNumbers) {
return
}
if (
new Set(request.pageNumbers).size !== request.pageNumbers.length
) {
context.addIssue({
code: 'custom',
path: ['pageNumbers'],
message: 'OCR 页码不能重复'
})
}
if (request.pageNumbers.length > request.maximumPages) {
context.addIssue({
code: 'custom',
path: ['pageNumbers'],
message: 'OCR 页数超过当前文档限制'
})
}
})
export const documentOcrSectionSchema = z export const documentOcrSectionSchema = z
.object({ .object({
@@ -280,6 +295,9 @@ export const documentOcrFailureSchema = z
export type DocumentParsingPurpose = z.infer< export type DocumentParsingPurpose = z.infer<
typeof documentParsingPurposeSchema typeof documentParsingPurposeSchema
> >
export type DocumentParsingTestPurpose = z.infer<
typeof documentParsingTestPurposeSchema
>
export type DocumentParsingSettings = z.infer< export type DocumentParsingSettings = z.infer<
typeof documentParsingSettingsSchema typeof documentParsingSettingsSchema
> >