Files
goodbuddy/src/main/document-parsing-service.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

697 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { extname } from 'node:path'
import {
documentParsingDiagnosticSchema,
documentParsingSettingsUpdateSchema,
documentParsingSnapshotSchema,
defaultHttpOcrSettings,
maximumDocumentExtractedCharacters,
maximumDocumentParsingWarnings,
type DocumentParsingDiagnostic,
type DocumentParsingPurpose,
type DocumentParsingSettings,
type DocumentParsingSnapshot
} from '../shared/document-parsing-contracts'
import type { DocumentOcrBroker } from './document-ocr-broker'
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
import type { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { HttpDocumentOcr } from './http-document-ocr'
import { hasExtractedDocumentText } from './document-extracted-text'
import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
import type { DocumentResultStorageAccess } from './desktop-storage-files'
import {
extractPdfTextPagesOffMain,
extractPptxPagesOffMain,
parseDocumentOffMain
} from './document-parse-client'
import {
assertDocumentBuffer,
DocumentTextUnavailableError,
type ParsedDocument,
type ParsedSection,
type PdfTextPage
} from './knowledge/document-parser'
const minimumUsefulPdfCharacters = 12
const maximumReplacementCharacterRatio = 0.08
export type ParseDocumentForPurpose = (
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose,
signal?: AbortSignal
) => Promise<ParsedDocument>
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error('文档解析已取消')
}
}
function hasUsefulText(content: string): boolean {
const compact = content.replace(/\s+/gu, '')
if (compact.length < minimumUsefulPdfCharacters) {
return false
}
const replacementCount = [...compact].filter(
(character) => character === '\uFFFD'
).length
return replacementCount / compact.length <=
maximumReplacementCharacterRatio
}
function effectiveOcrMode(
settings: DocumentParsingSettings,
purpose: DocumentParsingPurpose
): 'auto' | 'always' | 'disabled' {
if (
((purpose === 'chat-attachment' ||
purpose === 'artifact-import') &&
settings.chatWorkflow === 'fast-text') ||
(purpose === 'knowledge-index' &&
settings.knowledgeWorkflow === 'fast-index')
) {
return 'disabled'
}
if (
((purpose === 'chat-attachment' ||
purpose === 'artifact-import') &&
settings.chatWorkflow === 'high-fidelity') ||
(purpose === 'knowledge-index' &&
settings.knowledgeWorkflow === 'high-fidelity')
) {
return 'always'
}
return 'auto'
}
function buildPagedDocument(
name: string,
sections: ParsedSection[],
pageCount: number,
warnings: string[] = [],
sourceFormat: '.pdf' | '.pptx' = '.pdf'
): ParsedDocument {
const truncationWarning =
'文档提取文本超过 5,000,000 字符,已截断'
const boundedWarnings = [
...new Set(
warnings.filter((warning) => warning !== truncationWarning)
)
]
const limitedSections: ParsedSection[] = []
let remaining = maximumDocumentExtractedCharacters
let truncated = false
for (const section of sections) {
const separatorLength = limitedSections.length > 0 ? 2 : 0
if (remaining <= separatorLength) {
truncated = true
break
}
const content = section.content.slice(0, remaining - separatorLength)
if (content) {
limitedSections.push(
content === section.content ? section : { ...section, content }
)
remaining -= separatorLength + content.length
}
if (content.length < section.content.length) {
truncated = true
break
}
}
if (limitedSections.length < sections.length) {
truncated = true
}
const content = limitedSections
.map((section) => section.content)
.join('\n\n')
if (!content) {
throw new DocumentTextUnavailableError()
}
const documentWarnings =
truncated || warnings.includes(truncationWarning)
? [
...boundedWarnings.slice(0, maximumDocumentParsingWarnings - 1),
truncationWarning
]
: boundedWarnings.slice(0, maximumDocumentParsingWarnings)
return {
title: name.replace(/\.[^.]+$/u, ''),
sourceFormat,
content,
sections: limitedSections,
pageCount,
warnings: documentWarnings
}
}
function nativePdfSections(pages: PdfTextPage[]): ParsedSection[] {
return pages
.filter((page) => page.content.length > 0)
.map((page) => ({
locator: `第 ${page.pageNumber} 页`,
content: page.content,
method: 'native' as const,
pageNumber: page.pageNumber,
blockKind: 'text' as const
}))
}
export class DocumentParsingService {
private readonly diagnosticOperations = new Map<string, AbortController>()
private readonly diagnostics = new Map<AbortController, Promise<void>>()
private disposed = false
constructor(
private readonly settingsStore: DocumentParsingSettingsStore,
private readonly modelManager: DocumentOcrModelManager,
private readonly ocrBroker: DocumentOcrBroker,
readonly results?: DocumentResultStorageAccess
) {}
async snapshot(): Promise<DocumentParsingSnapshot> {
const settings = await this.settingsStore.get()
const [localOcr, ocrModels] = await Promise.all([
this.modelManager.getStatus(settings.localOcrModelId),
this.modelManager.getSnapshot()
])
return documentParsingSnapshotSchema.parse({
settings,
status: {
nativeParsingAvailable: true,
conversionAvailable: false,
localOcr
},
ocrModels,
httpCredentialConfigured: await this.settingsStore.credentialConfigured(),
...(this.settingsStore.getWarnings().length > 0
? { warnings: [...this.settingsStore.getWarnings()] }
: {})
})
}
async update(input: unknown): Promise<DocumentParsingSnapshot> {
const nextSettings =
documentParsingSettingsUpdateSchema.parse(input)
const currentSettings = await this.settingsStore.get()
if (nextSettings.ocrProvider === 'paddleocr-vl') {
if (!nextSettings.httpOcr?.baseUrl) throw new Error('请输入 HTTP OCR 服务地址')
if (nextSettings.httpOcr.authentication === 'bearer' && !nextSettings.apiKey && (nextSettings.clearApiKey || !await this.settingsStore.credentialConfigured())) throw new Error('请输入 Bearer API Key,或选择无需认证')
}
if (
nextSettings.ocrProvider !== 'paddleocr-vl' &&
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()
}
async checkHttp(): Promise<{ checkedAt: string; declaredOptions: string[] }> {
return this.httpProvider(await this.settingsStore.forOperation()).check()
}
async extractImage(name: string, buffer: Buffer, signal?: AbortSignal): Promise<ParsedDocument> {
const startedAt = Date.now()
ensureNotAborted(signal)
assertDocumentBuffer(buffer)
const operation = await this.settingsStore.forOperation()
const { settings } = operation
let sections: ParsedSection[]
let images: ParsedDocument['images']
let missingImages: ParsedDocument['missingImages']
let warnings: string[]
if (settings.ocrProvider === 'paddleocr-vl') {
const result = await this.httpProvider(operation).recognize(buffer, 1, [1], signal)
sections = result.sections
images = result.images
missingImages = result.missingImages
warnings = result.warnings
} else {
const status = await this.modelManager.getStatus(settings.localOcrModelId)
if (!status.available || !status.verified) throw new Error(status.detail)
const mimeType = extname(name).toLowerCase() === '.png' ? 'image/png' as const
: extname(name).toLowerCase() === '.webp' ? 'image/webp' as const : 'image/jpeg' as const
const result = await this.ocrBroker.recognize({ modelId: settings.localOcrModelId, fileName: name, mimeType,
data: Uint8Array.from(buffer).buffer, maximumPages: 1, pageTimeoutSeconds: settings.pageTimeoutSeconds }, signal)
sections = result.sections.map((section) => ({ ...section, method: 'ocr' }))
warnings = result.warnings
}
const content = sections.map((section) => section.content).join('\n\n')
if (!hasExtractedDocumentText(content)) throw new Error('未提取到文字,原图片发送方式保持不变')
ensureNotAborted(signal)
return { title: name, sourceFormat: extname(name).toLowerCase(), content, sections, warnings, pageCount: 1, images, missingImages, parsingSettings: settings, parsingDurationMs: Date.now() - startedAt }
}
private httpProvider(operation: Awaited<ReturnType<DocumentParsingSettingsStore['forOperation']>>): HttpDocumentOcr {
const { settings } = operation
const http = settings.httpOcr ?? defaultHttpOcrSettings
return new HttpDocumentOcr(http, http.authentication === 'bearer' ? operation.apiKey() : undefined)
}
parse: ParseDocumentForPurpose = async (
name,
buffer,
purpose,
signal
) => {
const started = Date.now()
const operation = await this.settingsStore.forOperation()
const parsed = await this.parseInput(name, buffer, purpose, operation, signal)
return { ...parsed, parsingSettings: operation.settings, parsingDurationMs: Date.now() - started }
}
private async parseInput(
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose,
operation: Awaited<ReturnType<DocumentParsingSettingsStore['forOperation']>>,
signal?: AbortSignal
): Promise<ParsedDocument> {
const { settings } = operation
ensureNotAborted(signal)
assertDocumentBuffer(buffer)
if (extname(name).toLowerCase() === '.pptx') {
return this.parsePptx(name, buffer, purpose, operation, signal)
}
if (extname(name).toLowerCase() !== '.pdf') {
return parseDocumentOffMain(name, buffer, signal)
}
const extracted = await extractPdfTextPagesOffMain(buffer, { signal })
const { pages } = extracted
ensureNotAborted(signal)
const mode = effectiveOcrMode(settings, purpose)
const pagesWithoutUsefulText = pages
.filter((page) => !hasUsefulText(page.content))
.map((page) => page.pageNumber)
const ocrPageNumbers =
mode === 'always'
? pages.map((page) => page.pageNumber)
: mode === 'auto'
? pagesWithoutUsefulText
: []
if (mode === 'disabled') {
const native = nativePdfSections(pages)
if (native.length > 0) {
const warnings = [
...(pagesWithoutUsefulText.length > 0
? ['部分页面没有有效文本,当前工作流未启用 OCR']
: []),
...(extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: [])
]
return buildPagedDocument(
name,
native,
extracted.pageCount,
warnings
)
}
throw new DocumentTextUnavailableError(
'PDF 没有可用文本层,当前工作流未启用 OCR'
)
}
if (ocrPageNumbers.length === 0) {
return buildPagedDocument(
name,
nativePdfSections(pages),
extracted.pageCount,
extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: []
)
}
if (ocrPageNumbers.length > settings.maximumPages) {
throw new Error(
`PDF 有 ${ocrPageNumbers.length} 页需要 OCR,超过 ${settings.maximumPages} 页限制`
)
}
const native = nativePdfSections(pages)
if (settings.ocrProvider === 'paddleocr-vl') {
const provider = this.httpProvider(operation)
const recognized: ParsedSection[] = []
const images: NonNullable<ParsedDocument['images']> = []
const missingImages: NonNullable<ParsedDocument['missingImages']> = []
const warnings: string[] = []
let restructure: ParsedDocument['restructure']
try {
if (ocrPageNumbers.length === extracted.pageCount && (mode === 'always' || settings.httpOcr?.mergeTables || settings.httpOcr?.relevelTitles)) {
const result = await provider.recognize(buffer, 0, ocrPageNumbers, signal)
recognized.push(...result.sections)
images.push(...(result.images ?? []))
missingImages.push(...(result.missingImages ?? []))
warnings.push(...result.warnings)
restructure = result.restructure
} else if (settings.httpOcr?.mergeTables || settings.httpOcr?.relevelTitles) {
const groups: number[][] = []
for (const page of ocrPageNumbers) {
const current = groups.at(-1)
if (current && current.at(-1) === page - 1) current.push(page)
else groups.push([page])
}
for (const group of groups) {
const selected = await renderSelectedOcrPdf(buffer, group, signal)
const result = await provider.recognize(selected, 0, group, signal)
recognized.push(...result.sections)
images.push(...(result.images ?? []))
missingImages.push(...(result.missingImages ?? []))
warnings.push(...result.warnings)
if (result.restructure) restructure = { changed: Boolean(restructure?.changed || result.restructure.changed), sourcePages: [...(restructure?.sourcePages ?? []), ...group] }
}
} else {
for await (const page of renderOcrPdf(buffer, ocrPageNumbers, signal)) {
const result = await provider.recognize(page.data, 1, [page.pageNumber], signal)
recognized.push(...result.sections)
images.push(...(result.images ?? []))
missingImages.push(...(result.missingImages ?? []))
warnings.push(...result.warnings)
}
}
if (warnings.length && (mode === 'always' || purpose === 'knowledge-index')) {
throw new Error(warnings.join(';'))
}
} catch (error) {
ensureNotAborted(signal)
if (mode !== 'auto' || purpose === 'knowledge-index' || !native.some((section) => hasUsefulText(section.content))) throw error
return buildPagedDocument(name, native, extracted.pageCount, [`HTTP OCR 未完成,已保留原生文本:${error instanceof Error ? error.message : '请求失败'}`])
}
const merged = [...native.filter((section) => !ocrPageNumbers.includes(section.pageNumber!)), ...recognized]
.sort((left, right) => (left.pageNumber ?? left.sourcePages?.[0] ?? 0) - (right.pageNumber ?? right.sourcePages?.[0] ?? 0))
return { ...buildPagedDocument(name, merged, extracted.pageCount, warnings), images, missingImages, restructure }
}
const modelStatus = await this.modelManager.getStatus(
settings.localOcrModelId
)
if (!modelStatus.available || !modelStatus.verified) {
if (
mode === 'auto' &&
purpose !== 'knowledge-index' &&
native.some((section) => hasUsefulText(section.content))
) {
return buildPagedDocument(
name,
native,
extracted.pageCount,
[
`本地 OCR 不可用,已保留 PDF 文本层内容:${modelStatus.detail}`,
...(extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: [])
]
)
}
throw new Error(modelStatus.detail)
}
const ocrRequest = {
modelId: settings.localOcrModelId,
fileName: name,
mimeType: 'application/pdf' as const,
data: Uint8Array.from(buffer).buffer,
maximumPages: settings.maximumPages,
pageNumbers: ocrPageNumbers,
pageTimeoutSeconds: settings.pageTimeoutSeconds
}
let ocr
try {
ocr = await (signal
? this.ocrBroker.recognize(ocrRequest, signal)
: this.ocrBroker.recognize(ocrRequest))
} catch (error) {
ensureNotAborted(signal)
if (
mode === 'auto' &&
purpose !== 'knowledge-index' &&
native.some((section) => hasUsefulText(section.content))
) {
const detail =
error instanceof Error ? error.message : '本地 OCR 识别失败'
return buildPagedDocument(
name,
native,
extracted.pageCount,
[
`本地 OCR 失败,已保留 PDF 文本层内容:${detail}`,
...(extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: [])
]
)
}
throw error
}
ensureNotAborted(signal)
const ocrByPageNumber = new Map(
ocr.sections.flatMap((section) =>
section.pageNumber === undefined
? []
: [[section.pageNumber, section] as const]
)
)
const missingOcrPage = ocrPageNumbers.find(
(pageNumber) => !ocrByPageNumber.has(pageNumber)
)
if (
missingOcrPage !== undefined &&
(purpose === 'knowledge-index' || mode === 'always')
) {
throw new Error(`第 ${missingOcrPage} 页未识别到可索引文本`)
}
const merged = pages.flatMap((page): ParsedSection[] => {
const locator = `第 ${page.pageNumber} 页`
const recognized = ocrByPageNumber.get(page.pageNumber)
if (
recognized &&
(mode === 'always' || !hasUsefulText(page.content))
) {
return [
{
locator,
content: recognized.content,
method: 'ocr',
confidence: recognized.confidence,
pageNumber: page.pageNumber,
blockKind: 'text'
}
]
}
return page.content
? [{
locator,
content: page.content,
method: 'native',
pageNumber: page.pageNumber,
blockKind: 'text'
}]
: []
})
return buildPagedDocument(
name,
merged,
extracted.pageCount,
[
...ocr.warnings,
...(missingOcrPage === undefined ? [] : [`第 ${missingOcrPage} 页未完成 OCR,当前为部分解析结果`]),
...(extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: [])
]
)
}
private async parsePptx(
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose,
operation: Awaited<ReturnType<DocumentParsingSettingsStore['forOperation']>>,
signal?: AbortSignal
): Promise<ParsedDocument> {
const { settings } = operation
const mode = effectiveOcrMode(settings, purpose)
if (mode === 'disabled') {
try {
return await parseDocumentOffMain(name, buffer, signal)
} catch (error) {
if (error instanceof DocumentTextUnavailableError) {
throw new DocumentTextUnavailableError(
'PPTX 没有可用文本,当前工作流未启用 OCR'
)
}
throw error
}
}
const pages = await extractPptxPagesOffMain(buffer)
ensureNotAborted(signal)
const native: ParsedSection[] = pages
.filter((page) => page.content)
.map((page) => ({
locator: `幻灯片 ${page.pageNumber}`,
content: page.content,
method: 'native',
pageNumber: page.pageNumber,
blockKind: 'slide'
}))
const imagePages = pages.filter((page) => page.images.length > 0)
if (imagePages.length > settings.maximumPages) {
throw new Error(
`PPTX 有 ${imagePages.length} 页需要 OCR,超过 ${settings.maximumPages} 页限制`
)
}
const sections = [...native]
const images: NonNullable<ParsedDocument['images']> = []
const missingImages: NonNullable<ParsedDocument['missingImages']> = []
const warnings: string[] = []
let extractedCharacters = native.reduce(
(total, section) => total + section.content.length, 0
)
let activeLocator: string | undefined
if (imagePages.length > 0) {
try {
const http = settings.ocrProvider === 'paddleocr-vl' ? this.httpProvider(operation) : undefined
if (!http) {
const status = await this.modelManager.getStatus(settings.localOcrModelId)
if (!status.available || !status.verified) throw new Error(status.detail)
}
ocrPages: for (const page of imagePages) {
for (const [index, image] of page.images.entries()) {
ensureNotAborted(signal)
const locator = `幻灯片 ${page.pageNumber} · 图片 ${index + 1}`
activeLocator = locator
if (!image.mimeType) {
throw new Error(`${locator} 的格式暂不支持 OCR:${extname(image.name)}`)
}
const request = {
modelId: settings.localOcrModelId,
fileName: image.name,
mimeType: image.mimeType,
data: Uint8Array.from(image.data).buffer,
maximumPages: 1,
pageTimeoutSeconds: settings.pageTimeoutSeconds
}
const result = http ? await http.recognize(Buffer.from(image.data), 1, [page.pageNumber], signal) : await (signal
? this.ocrBroker.recognize(request, signal)
: this.ocrBroker.recognize(request))
if ('images' in result) images.push(...(result.images ?? []).map((image) => ({ ...image, locator })))
if ('missingImages' in result) missingImages.push(...(result.missingImages ?? []))
if (http && result.warnings.length && (mode === 'always' || purpose === 'knowledge-index')) throw new Error(result.warnings.join(';'))
ensureNotAborted(signal)
sections.push(...result.sections.map((section): ParsedSection => ({
locator,
content: section.content,
confidence: section.confidence,
method: 'ocr',
pageNumber: page.pageNumber,
blockKind: 'slide'
})))
for (const warning of result.warnings) {
if (warnings.length < maximumDocumentParsingWarnings) {
warnings.push(`${locator}:${warning}`.slice(0, 500))
}
}
extractedCharacters += result.sections.reduce(
(total, section) => total + section.content.length, 0
)
if (extractedCharacters >= maximumDocumentExtractedCharacters) {
warnings.push('文档提取文本超过 5,000,000 字符,已截断')
break ocrPages
}
}
}
} catch (error) {
ensureNotAborted(signal)
if (
mode !== 'auto' ||
purpose === 'knowledge-index' ||
!native.some((section) => hasUsefulText(section.content))
) {
if (activeLocator && error instanceof Error) throw new Error(`${activeLocator}:${error.message}`, { cause: error })
throw error
}
return buildPagedDocument(name, native, pages.length, [
`${settings.ocrProvider === 'paddleocr-vl' ? 'HTTP OCR' : '本地 OCR'} 未完成,已保留 PPTX 文本内容:${activeLocator ? `${activeLocator}:` : ''}${
error instanceof Error ? error.message : 'OCR 识别失败'
}`.slice(0, 500)
], '.pptx')
}
}
sections.sort((left, right) => (left.pageNumber ?? 0) - (right.pageNumber ?? 0))
return { ...buildPagedDocument(name, sections, pages.length, warnings, '.pptx'), ...(images.length ? { images } : {}), ...(missingImages.length ? { missingImages } : {}) }
}
async diagnose(
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose = 'diagnostic',
operationId?: string
): Promise<DocumentParsingDiagnostic> {
if (this.disposed) throw new Error('Document parsing service is closed')
const startedAt = Date.now()
const controller = new AbortController()
if (operationId) {
if (this.diagnosticOperations.has(operationId)) throw new Error('解析任务已在运行')
this.diagnosticOperations.set(operationId, controller)
}
let settle!: () => void
this.diagnostics.set(controller, new Promise(resolve => { settle = resolve }))
try {
const parsed = await this.parse(name, buffer, purpose, controller.signal)
controller.signal.throwIfAborted()
const saved = parsed.parsingSettings ? await this.results?.save(name, buffer, parsed, parsed.parsingSettings, Date.now() - startedAt, undefined, controller.signal) : undefined
const ocrPageCount = new Set(parsed.sections.filter(
(section) => section.method === 'ocr'
).flatMap((section) => (section.sourcePages ?? [section.pageNumber ?? section.locator]).map(String))).size
const nativePageCount = parsed.sections.filter(
(section) => section.method !== 'ocr'
).length
return documentParsingDiagnosticSchema.parse({
provider: ocrPageCount ? parsed.parsingSettings?.ocrProvider ?? 'local' : 'native',
...(saved ? { resultId: saved.id } : {}),
fileName: name,
sourceFormat:
parsed.sourceFormat.replace(/^\./u, '').toUpperCase() || 'UNKNOWN',
pageCount:
parsed.sourceFormat === '.pdf' || parsed.sourceFormat === '.pptx'
? (parsed.pageCount ?? parsed.sections.length)
: 0,
ocrPageCount,
characterCount: parsed.content.length,
method:
ocrPageCount > 0 && nativePageCount > 0
? 'mixed'
: ocrPageCount > 0
? 'ocr'
: 'native',
durationMs: Date.now() - startedAt,
preview: parsed.content.slice(0, 2_000),
warnings: parsed.warnings
})
} finally {
if (operationId) this.diagnosticOperations.delete(operationId)
this.diagnostics.delete(controller)
settle()
}
}
cancelDiagnostic(operationId: string): void {
this.diagnosticOperations.get(operationId)?.abort(new Error('文档解析已取消'))
}
async dispose(): Promise<void> {
this.disposed = true
for (const controller of this.diagnostics.keys()) controller.abort(new Error('应用正在退出'))
await Promise.all(this.diagnostics.values())
await this.results?.close()
}
}