fix: streamline document parsing settings
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '解析测试结果',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user