From 86b63406c27325f46be3834d335c34eaf171ae49 Mon Sep 17 00:00:00 2001 From: lofyer Date: Thu, 13 Aug 2026 00:08:15 +0800 Subject: [PATCH] fix: streamline document parsing settings --- src/main/document-ocr-broker.test.ts | 89 +++++ src/main/document-ocr-broker.ts | 148 ++++++--- src/main/document-ocr-model-manager.test.ts | 11 + src/main/document-ocr-model-manager.ts | 13 +- src/main/document-parsing-service.test.ts | 192 +++++++++-- src/main/document-parsing-service.ts | 64 +++- .../document-parsing-settings-store.test.ts | 54 ++- src/main/document-parsing-settings-store.ts | 89 +++-- src/main/ipc.test.ts | 143 ++++++++ src/main/ipc.ts | 18 +- src/preload/index.ts | 8 +- src/preload/preload-sandbox.test.ts | 13 + .../DocumentParsingSettingsSection.test.tsx | 145 +++++--- .../src/DocumentParsingSettingsSection.tsx | 312 +++++++++--------- src/renderer/src/document-ocr-bridge.ts | 44 ++- src/renderer/src/document-ocr-worker.ts | 28 +- .../src/i18n/locales/en-US/settings.ts | 85 ++--- .../src/i18n/locales/zh-CN/settings.ts | 77 ++--- src/renderer/src/styles.css | 85 +---- src/shared/contracts.ts | 7 +- src/shared/document-parsing-contracts.ts | 44 ++- 21 files changed, 1160 insertions(+), 509 deletions(-) diff --git a/src/main/document-ocr-broker.test.ts b/src/main/document-ocr-broker.test.ts index 0394e9a..b418878 100644 --- a/src/main/document-ocr-broker.test.ts +++ b/src/main/document-ocr-broker.test.ts @@ -50,4 +50,93 @@ describe('DocumentOcrBroker', () => { ).toThrow('OCR 解析已取消') 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 解析已取消') + }) }) diff --git a/src/main/document-ocr-broker.ts b/src/main/document-ocr-broker.ts index 5fd6664..f978088 100644 --- a/src/main/document-ocr-broker.ts +++ b/src/main/document-ocr-broker.ts @@ -9,17 +9,23 @@ import { } from '../shared/document-parsing-contracts' type PendingRequest = { + request: DocumentOcrRequest resolve: (result: DocumentOcrResult) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType + timeoutMs: number detachAbort: () => void + dispatched: boolean } const maximumPendingRequests = 4 const maximumTotalTimeoutMs = 10 * 60 * 1_000 +const workerStartupTimeoutMs = 60 * 1_000 export class DocumentOcrBroker { private readonly pending = new Map() + private readonly queue: string[] = [] + private activeRequestId?: string private disposed = false constructor(private readonly window: BrowserWindow) {} @@ -41,50 +47,37 @@ export class DocumentOcrBroker { if (signal?.aborted) { throw new Error('OCR 解析已取消') } + const pageCount = + request.pageNumbers?.length ?? request.maximumPages const timeoutMs = Math.min( maximumTotalTimeoutMs, Math.max( request.pageTimeoutSeconds * 1_000, - request.pageTimeoutSeconds * - request.maximumPages * - 1_000 + workerStartupTimeoutMs + + request.pageTimeoutSeconds * + pageCount * + 1_000 ) ) return new Promise((resolve, reject) => { - const cancel = (message: string): void => { - const pending = this.pending.get(request.requestId) - 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 解析已取消') + const onAbort = (): void => + this.cancelRequest(request.requestId, 'OCR 解析已取消') signal?.addEventListener('abort', onAbort, { once: true }) this.pending.set(request.requestId, { + request, resolve, reject, - timer, + timeoutMs, detachAbort: () => - signal?.removeEventListener('abort', onAbort) + signal?.removeEventListener('abort', onAbort), + dispatched: false }) + this.queue.push(request.requestId) if (signal?.aborted) { - cancel('OCR 解析已取消') + this.cancelRequest(request.requestId, 'OCR 解析已取消') return } - this.window.webContents.send( - ipcChannels.documentParsingOcrRequest, - request - ) + this.dispatchNext() }) } @@ -102,30 +95,111 @@ export class DocumentOcrBroker { throw new Error('OCR 响应无效') } const pending = this.pending.get(requestId) - if (!pending) { + if (!pending || !pending.dispatched) { return } - clearTimeout(pending.timer) - pending.detachAbort() - this.pending.delete(requestId) if (result.success) { - pending.resolve(result.data) + this.finishRequest(requestId, () => + pending.resolve(result.data) + ) } else { if (!failure?.success) { - pending.reject(new Error('OCR 响应无效')) + this.finishRequest(requestId, () => + pending.reject(new Error('OCR 响应无效')) + ) return } - pending.reject(new Error(failure.data.error)) + this.finishRequest(requestId, () => + pending.reject(new Error(failure.data.error)) + ) } } dispose(): void { this.disposed = true for (const pending of this.pending.values()) { - clearTimeout(pending.timer) + if (pending.timer) { + clearTimeout(pending.timer) + } pending.detachAbort() pending.reject(new Error('OCR 解析已取消')) } 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() } } diff --git a/src/main/document-ocr-model-manager.test.ts b/src/main/document-ocr-model-manager.test.ts index 0a25a3b..aca50dc 100644 --- a/src/main/document-ocr-model-manager.test.ts +++ b/src/main/document-ocr-model-manager.test.ts @@ -163,6 +163,17 @@ afterEach(async () => { }) 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', () => { expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3) expect( diff --git a/src/main/document-ocr-model-manager.ts b/src/main/document-ocr-model-manager.ts index ae5bd6f..7ee4f84 100644 --- a/src/main/document-ocr-model-manager.ts +++ b/src/main/document-ocr-model-manager.ts @@ -205,7 +205,18 @@ export class DocumentOcrModelManager { async getStatus( modelId: string ): Promise> { - 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 { await this.getVerifiedStatus(entry) return documentParsingModelStatusSchema.parse({ diff --git a/src/main/document-parsing-service.test.ts b/src/main/document-parsing-service.test.ts index 6fb139d..556fb5d 100644 --- a/src/main/document-parsing-service.test.ts +++ b/src/main/document-parsing-service.test.ts @@ -4,14 +4,29 @@ import { } from './document-parsing-settings-store' import { DocumentParsingService } from './document-parsing-service' -function createPdfFixture(text: string): Buffer { - const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET` +function createPdfFixture(...pageTexts: string[]): Buffer { + const texts = pageTexts.length > 0 ? pageTexts : [''] + const fontObjectId = texts.length + 3 + const firstContentObjectId = fontObjectId + 1 const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', - '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + `<< /Type /Pages /Kids [${texts + .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 >>', - `<< /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' const offsets = [0] @@ -33,41 +48,62 @@ function createPdfFixture(text: string): Buffer { function createService(overrides?: { settings?: Partial + 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 = { ...defaultDocumentParsingSettings, ...overrides?.settings } - const recognize = vi.fn(async () => ({ - requestId: crypto.randomUUID(), - sections: [ - { - locator: '第 1 页', - content: '扫描件识别正文', - confidence: 0.93 - } - ], - pageCount: 1, - warnings: [] - })) - const service = new DocumentParsingService( - { - get: vi.fn(async () => settings), - update: vi.fn(async () => settings) - } as never, - { - getStatus: vi.fn(async () => ({ + const recognize = vi.fn( + overrides?.recognize ?? + (async () => ({ + requestId: crypto.randomUUID(), + sections: [ + { + locator: '第 1 页', + content: '扫描件识别正文', + confidence: 0.93 + } + ], + pageCount: 1, + warnings: [] + })) + ) + const settingsStore = { + get: vi.fn(async () => settings), + update: vi.fn(async () => settings) + } + const modelManager = { + getStatus: vi.fn(async () => ({ id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', - available: true, - verified: true, + available: overrides?.modelStatus?.available ?? true, + verified: overrides?.modelStatus?.verified ?? true, runtime: 'onnxruntime-web-wasm', - detail: '可用' - })) - } as never, + detail: overrides?.modelStatus?.detail ?? '可用' + })), + getSnapshot: vi.fn() + } + const service = new DocumentParsingService( + settingsStore as never, + modelManager as never, { recognize } as never ) - return { recognize, service } + return { modelManager, recognize, service, settingsStore } } describe('DocumentParsingService', () => { @@ -127,4 +163,100 @@ describe('DocumentParsingService', () => { ).rejects.toThrow('未启用 OCR') 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() + }) }) diff --git a/src/main/document-parsing-service.ts b/src/main/document-parsing-service.ts index dc637c2..e2c18a7 100644 --- a/src/main/document-parsing-service.ts +++ b/src/main/document-parsing-service.ts @@ -1,6 +1,7 @@ import { extname } from 'node:path' import { documentParsingDiagnosticSchema, + documentParsingSettingsUpdateSchema, documentParsingSnapshotSchema, type DocumentParsingDiagnostic, type DocumentParsingPurpose, @@ -52,9 +53,10 @@ function hasUsefulText(content: string): boolean { function effectiveOcrMode( settings: DocumentParsingSettings, purpose: DocumentParsingPurpose -): DocumentParsingSettings['pdfOcrMode'] { +): 'auto' | 'always' | 'disabled' { if ( - (purpose === 'chat-attachment' && + ((purpose === 'chat-attachment' || + purpose === 'artifact-import') && settings.chatWorkflow === 'fast-text') || (purpose === 'knowledge-index' && settings.knowledgeWorkflow === 'fast-index') @@ -62,14 +64,15 @@ function effectiveOcrMode( return 'disabled' } if ( - (purpose === 'chat-attachment' && + ((purpose === 'chat-attachment' || + purpose === 'artifact-import') && settings.chatWorkflow === 'high-fidelity') || (purpose === 'knowledge-index' && settings.knowledgeWorkflow === 'high-fidelity') ) { return 'always' } - return settings.pdfOcrMode + return 'auto' } function buildPdfDocument( @@ -130,7 +133,21 @@ export class DocumentParsingService { } async update(input: unknown): Promise { - 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() } @@ -159,7 +176,7 @@ export class DocumentParsingService { ? pagesWithoutUsefulText : [] - if (mode === 'disabled' || !settings.localOcrEnabled) { + if (mode === 'disabled') { const native = nativePdfSections(pages) if (native.length > 0) { return buildPdfDocument( @@ -182,15 +199,24 @@ export class DocumentParsingService { pages.length ) } - if (pages.length > settings.maximumPages) { + if (ocrPageNumbers.length > settings.maximumPages) { 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( settings.localOcrModelId ) 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) } @@ -203,9 +229,25 @@ export class DocumentParsingService { pageNumbers: ocrPageNumbers, pageTimeoutSeconds: settings.pageTimeoutSeconds } - const ocr = await (signal - ? this.ocrBroker.recognize(ocrRequest, signal) - : this.ocrBroker.recognize(ocrRequest)) + let ocr + try { + 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) const ocrByLocator = new Map( ocr.sections.map((section) => [section.locator, section]) diff --git a/src/main/document-parsing-settings-store.test.ts b/src/main/document-parsing-settings-store.test.ts index ccdfab8..af1df02 100644 --- a/src/main/document-parsing-settings-store.test.ts +++ b/src/main/document-parsing-settings-store.test.ts @@ -60,7 +60,7 @@ describe('DocumentParsingSettingsStore', () => { await expect(store.update(settings)).resolves.toEqual(settings) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 2, + version: 3, ...settings }) await expect( @@ -68,27 +68,59 @@ describe('DocumentParsingSettingsStore', () => { ).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 { - ocrProvider: _ocrProvider, - ...legacySettings - } = defaultDocumentParsingSettings - void _ocrProvider await writeFile( filePath, JSON.stringify({ version: 1, - ...legacySettings, + chatWorkflow: 'auto', + knowledgeWorkflow: 'complete-index', + pdfOcrMode: 'auto', + localOcrEnabled: false, + localOcrModelId: 'pp-ocrv6-tiny', + maximumPages: 100, + ocrConcurrency: 1, + pageTimeoutSeconds: 60, chatCloudPermission: 'always', knowledgeCloudPermission: 'never' }), 'utf8' ) - await expect(store.get()).resolves.toEqual( - defaultDocumentParsingSettings - ) + await expect(store.get()).resolves.toEqual({ + ...defaultDocumentParsingSettings, + chatWorkflow: 'fast-text', + knowledgeWorkflow: 'fast-index' + }) }) it('rejects incomplete or out-of-range settings', async () => { diff --git a/src/main/document-parsing-settings-store.ts b/src/main/document-parsing-settings-store.ts index 803e00b..acac021 100644 --- a/src/main/document-parsing-settings-store.ts +++ b/src/main/document-parsing-settings-store.ts @@ -14,7 +14,7 @@ import { type DocumentParsingSettings } from '../shared/document-parsing-contracts' -const CURRENT_SETTINGS_VERSION = 2 +const CURRENT_SETTINGS_VERSION = 3 const storedDocumentParsingSettingsSchema = documentParsingSettingsSchema @@ -27,9 +27,32 @@ type StoredDocumentParsingSettings = z.infer< typeof storedDocumentParsingSettingsSchema > -const legacyDocumentParsingSettingsSchema = - documentParsingSettingsSchema - .omit({ ocrProvider: true }) +const legacyVersionTwoSettingsSchema = z + .object({ + 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({ version: z.literal(1), chatCloudPermission: z.enum(['ask', 'always', 'never']), @@ -40,15 +63,39 @@ const legacyDocumentParsingSettingsSchema = export const defaultDocumentParsingSettings: DocumentParsingSettings = { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', - pdfOcrMode: 'auto', - ocrProvider: 'local', - localOcrEnabled: true, localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, - ocrConcurrency: 1, 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 { return ( error !== null && @@ -99,23 +146,27 @@ export class DocumentParsingSettingsStore { const result = storedDocumentParsingSettingsSchema.safeParse(parsed) if (!result.success) { - const legacy = - legacyDocumentParsingSettingsSchema.safeParse(parsed) - if (legacy.success) { + const versionTwo = + legacyVersionTwoSettingsSchema.safeParse(parsed) + if (versionTwo.success) { + this.settings = migrateLegacySettings(versionTwo.data) + return this.settings + } + const versionOne = + legacyVersionOneSettingsSchema.safeParse(parsed) + if (versionOne.success) { const { - version: _version, chatCloudPermission: _chatCloudPermission, knowledgeCloudPermission: _knowledgeCloudPermission, - ...settings - } = legacy.data - void _version + ...legacy + } = versionOne.data void _chatCloudPermission void _knowledgeCloudPermission - this.settings = { - version: CURRENT_SETTINGS_VERSION, + this.settings = migrateLegacySettings({ + ...legacy, + version: 2, ocrProvider: 'local', - ...settings - } + }) return this.settings } await this.isolateCorruptFile() diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index d15e7ad..9e0c9aa 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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', () => { afterEach(() => { electronMocks.handlers.clear() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ed11206..4e53439 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -102,7 +102,8 @@ import { documentOcrModelActionInputSchema, documentOcrFailureSchema, documentOcrResultSchema, - documentParsingSettingsUpdateSchema + documentParsingSettingsUpdateSchema, + documentParsingTestInputSchema } from '../shared/document-parsing-contracts' import { agentRuntimeSelectionSchema, @@ -2785,11 +2786,13 @@ export function registerIpcHandlers( ipcMain.handle( ipcChannels.documentParsingTest, - async (event) => { + async (event, input: unknown) => { assertTrustedSender(event, window) if (!documentParsingService) { throw new Error('文档解析设置服务不可用') } + const { purpose } = + documentParsingTestInputSchema.parse(input) const result = await dialog.showOpenDialog(window, { title: '选择测试文档', properties: ['openFile'], @@ -2814,7 +2817,8 @@ export function registerIpcHandlers( } return documentParsingService.diagnose( basename(canonicalPath), - await readFile(canonicalPath) + await readFile(canonicalPath), + purpose ) } catch (error) { if (error instanceof Error && !('code' in error)) { @@ -3436,7 +3440,13 @@ export function registerIpcHandlers( ) continue } - const parsed = await parseDocument(name, file) + const parsed = documentParsingService + ? await documentParsingService.parse( + name, + file, + 'artifact-import' + ) + : await parseDocument(name, file) artifacts.push( assistantDatabase.createInlineArtifact({ projectId, diff --git a/src/preload/index.ts b/src/preload/index.ts index 443fe42..5c940fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,7 +85,8 @@ import type { DocumentOcrResult, DocumentParsingDiagnostic, DocumentParsingSettings, - DocumentParsingSnapshot + DocumentParsingSnapshot, + DocumentParsingTestPurpose } from '../shared/document-parsing-contracts' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import type { WeixinBindingSnapshot } from '../shared/weixin-channel-contracts' @@ -435,9 +436,10 @@ const desktopApi: DesktopApi = { ipcChannels.documentParsingUpdate, input ) as Promise, - test: () => + test: (purpose: DocumentParsingTestPurpose) => ipcRenderer.invoke( - ipcChannels.documentParsingTest + ipcChannels.documentParsingTest, + { purpose } ) as Promise, installOcrModel: (modelId: string) => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 7594133..34ae33a 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -50,6 +50,19 @@ describe('sandboxed preload', () => { 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', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/DocumentParsingSettingsSection.test.tsx b/src/renderer/src/DocumentParsingSettingsSection.test.tsx index 6008984..50fc4c8 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.test.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.test.tsx @@ -7,6 +7,7 @@ import { } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { + InstalledDocumentOcrModel, DocumentParsingSettings, DocumentParsingSnapshot } from '../../shared/document-parsing-contracts' @@ -16,12 +17,8 @@ import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection const settings: DocumentParsingSettings = { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', - pdfOcrMode: 'auto', - ocrProvider: 'local', - localOcrEnabled: true, localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, - ocrConcurrency: 1, pageTimeoutSeconds: 60 } @@ -139,35 +136,36 @@ const test = vi.fn(async () => ({ preview: '扫描件识别正文', warnings: [] })) -const installOcrModel = vi.fn(async () => ({ - ...snapshot, - status: { - ...snapshot.status, - localOcr: { - ...snapshot.status.localOcr, - available: true, - verified: true, - detail: '模型已安装并校验' - } - }, - ocrModels: { - ...snapshot.ocrModels, - installed: [ - { - id: 'pp-ocrv6-tiny' as const, - displayName: 'PP-OCRv6 Tiny', - source: 'download' as const, - installedAt: '2026-08-11T00:00:00.000Z', - files: modelEntry.files.map((file) => ({ - name: file.name, - role: file.role, - size: file.download.size, - sha256: file.download.sha256 - })) +const installOcrModel = + vi.fn<() => Promise>(async () => ({ + ...snapshot, + status: { + ...snapshot.status, + localOcr: { + ...snapshot.status.localOcr, + available: true, + verified: true, + detail: '模型已安装并校验' } - ] - } -})) + }, + ocrModels: { + ...snapshot.ocrModels, + installed: [ + { + id: 'pp-ocrv6-tiny', + displayName: 'PP-OCRv6 Tiny', + source: 'download', + installedAt: '2026-08-11T00:00:00.000Z', + files: modelEntry.files.map((file) => ({ + name: file.name, + role: file.role, + size: file.download.size, + sha256: file.download.sha256 + })) + } + ] + } + })) const importOcrModelArchive = vi.fn(async () => snapshot) const exportOcrModelArchive = vi.fn(async () => snapshot) const openOcrModelRepository = vi.fn(async () => undefined) @@ -212,17 +210,7 @@ describe('DocumentParsingSettingsSection', () => { expect(screen.getByText('质量:基础')).toBeInTheDocument() expect(screen.getByText('速度:快')).toBeInTheDocument() expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument() - expect( - screen.getByRole('switch', { name: /启用本地 OCR/u }) - ).toBeChecked() - expect( - screen.getByRole('button', { name: '本地模型' }) - ).toHaveAttribute('aria-pressed', 'true') - expect( - screen.getByRole('button', { - name: '远程服务(即将支持)' - }) - ).toBeDisabled() + expect(screen.queryByRole('switch')).not.toBeInTheDocument() expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument() expect( screen.queryByText('模型详情与手动导入') @@ -237,12 +225,15 @@ describe('DocumentParsingSettingsSection', () => { ) expect(openOcrModelRepository).toHaveBeenCalledWith('pp-ocrv6-tiny') - fireEvent.change(screen.getByLabelText('聊天附件'), { + fireEvent.change(screen.getByLabelText('聊天与成果文件'), { target: { value: 'fast-text' } }) - fireEvent.click( - screen.getByRole('button', { name: '保存设置' }) - ) + expect( + screen.getByRole('button', { + name: '测试聊天与成果模式' + }) + ).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) await waitFor(() => expect(update).toHaveBeenCalledWith( @@ -278,6 +269,59 @@ describe('DocumentParsingSettingsSection', () => { ) }) + it('downloads and selects an uninstalled model in one action', async () => { + const installedMedium = { + ...snapshot, + settings: { + ...snapshot.settings, + localOcrModelId: 'pp-ocrv6-medium' + } + } + installOcrModel.mockResolvedValueOnce({ + ...snapshot, + ocrModels: { + ...snapshot.ocrModels, + installed: [ + ...snapshot.ocrModels.installed, + { + id: 'pp-ocrv6-medium', + displayName: 'PP-OCRv6 Medium', + source: 'download', + installedAt: '2026-08-11T00:00:00.000Z', + files: thirdModelEntry.files.map((file) => ({ + name: file.name, + role: file.role, + size: file.download.size, + sha256: file.download.sha256 + })) + } satisfies InstalledDocumentOcrModel + ] + } + }) + update.mockResolvedValueOnce(installedMedium) + render() + + fireEvent.change(await screen.findByLabelText('当前 OCR 模型'), { + target: { value: 'pp-ocrv6-medium' } + }) + expect( + screen.getByRole('button', { name: '保存设置' }) + ).toBeDisabled() + fireEvent.click( + screen.getByRole('button', { + name: '下载 PP-OCRv6 Medium' + }) + ) + + await waitFor(() => + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + localOcrModelId: 'pp-ocrv6-medium' + }) + ) + ) + }) + it('imports and exports verified OCR model ZIP archives', async () => { const onNotify = vi.fn() render( @@ -370,7 +414,9 @@ describe('DocumentParsingSettingsSection', () => { await screen.findByText('PP-OCRv6 Tiny') fireEvent.click( - screen.getByRole('button', { name: '测试解析' }) + screen.getByRole('button', { + name: '测试聊天与成果模式' + }) ) expect( @@ -378,6 +424,7 @@ describe('DocumentParsingSettingsSection', () => { name: '解析测试结果' }) ).toHaveTextContent('扫描件识别正文') - expect(test).toHaveBeenCalledOnce() + expect(test).toHaveBeenCalledWith('chat-attachment') + expect(update).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/DocumentParsingSettingsSection.tsx b/src/renderer/src/DocumentParsingSettingsSection.tsx index e43d9f3..f498286 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.tsx @@ -25,11 +25,11 @@ import type { DocumentOcrModelCatalogEntry, DocumentOcrModelOperation, DocumentParsingSettings, - DocumentParsingSnapshot + DocumentParsingSnapshot, + DocumentParsingTestPurpose } from '../../shared/document-parsing-contracts' import type { AppNotificationInput } from './notifications' import { SettingsCategoryHeader } from './SettingsPrimitives' -import { SegmentedControl } from './WorkspacePrimitives' type DocumentParsingSettingsSectionProps = { onNotify?: (notification: AppNotificationInput) => void @@ -211,7 +211,8 @@ export function DocumentParsingSettingsSection({ const [draft, setDraft] = useState() const [error, setError] = useState() const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) + const [testingPurpose, setTestingPurpose] = + useState() const [busyModelId, setBusyModelId] = useState() const [confirmingRemove, setConfirmingRemove] = useState() const [diagnostic, setDiagnostic] = @@ -283,9 +284,7 @@ export function DocumentParsingSettingsSection({ ) } - const save = async ( - notify = true - ): Promise => { + const save = async (): Promise => { const api = window.goodbuddy.documentParsing if (!api || !draft) { return undefined @@ -296,13 +295,11 @@ export function DocumentParsingSettingsSection({ const next = await api.update(draft) setSnapshot(next) setDraft(next.settings) - if (notify) { - onNotify?.({ - tone: 'success', - message: t('notifications.documentParsingSaved'), - dedupeKey: 'document-parsing-saved' - }) - } + onNotify?.({ + tone: 'success', + message: t('notifications.documentParsingSaved'), + dedupeKey: 'document-parsing-saved' + }) return next } catch (reason) { setError( @@ -365,19 +362,18 @@ export function DocumentParsingSettingsSection({ ) } - const testParsing = async (): Promise => { + const testParsing = async ( + purpose: DocumentParsingTestPurpose + ): Promise => { const api = window.goodbuddy.documentParsing if (!api) { setError(t('errors.documentParsingUnavailable')) return } - setTesting(true) + setTestingPurpose(purpose) setError(undefined) try { - if (!(await save(false))) { - return - } - const result = await api.test() + const result = await api.test(purpose) if (result) { setDiagnostic(result) onNotify?.({ @@ -391,7 +387,7 @@ export function DocumentParsingSettingsSection({ errorMessage(reason, t('errors.testDocumentParsing')) ) } finally { - setTesting(false) + setTestingPurpose(undefined) } } @@ -415,6 +411,10 @@ export function DocumentParsingSettingsSection({ const model = snapshot.ocrModels.catalog.find( (entry) => entry.id === draft.localOcrModelId ) + const selectedModelInCatalog = model !== undefined + const currentModelInCatalog = snapshot.ocrModels.catalog.some( + (entry) => entry.id === snapshot.settings.localOcrModelId + ) const installedModel = snapshot.ocrModels.installed.find( (entry) => entry.id === draft.localOcrModelId ) @@ -426,38 +426,41 @@ export function DocumentParsingSettingsSection({ : undefined const pendingModelSelection = draft.localOcrModelId !== snapshot.settings.localOcrModelId + const settingsDirty = + JSON.stringify(draft) !== JSON.stringify(snapshot.settings) + const selectedModelReady = installedModel !== undefined + const invalidPendingModel = + pendingModelSelection && !selectedModelReady + const testing = testingPurpose !== undefined return ( <> - - - + } category="document-parsing" error={error} /> + {settingsDirty && ( +

+ {t('documentParsing.workflows.unsavedNotice')} +

+ )}
-
@@ -604,40 +657,6 @@ export function DocumentParsingSettingsSection({ -
-
- {t('documentParsing.ocr.provider.title')} - - {t('documentParsing.ocr.provider.description')} - -
- { - 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} - /> - - {t('documentParsing.ocr.provider.remoteDescription')} - -
- - {draft.ocrProvider === 'local' && ( - <>