Add HTTP PaddleOCR-VL settings and shared document previews for chat and knowledge imports. Preserve attachment originals and parsed assets across drafts, messages and queues; support explicit image OCR, selecting document images into a conversation draft, and restoring queued input for editing. Protect unsaved note bodies and titles during navigation and asynchronous updates. Match canvas analysis to source revisions, invalidate affected comments, preserve annotations during pagination, and provide a resizable record index with saved width preferences. Existing OCR settings retain local behavior. Historical attachments without saved originals are not reconstructed. Advanced OCR options depend on the configured service; image sending depends on the effective model and Runtime. Validation: full typecheck and lint passed; 477 focused tests passed across 12 files; staged whitespace checks passed. The full repository suite was not rerun for this commit. Release note: Keep note drafts safe while switching workspaces, resize the record index, and revisit saved document attachments with OCR previews and selected images in chat.
270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import { z } from 'zod'
|
|
import {
|
|
documentParsingSettingsSchema,
|
|
documentParsingSettingsUpdateSchema,
|
|
type DocumentParsingSettings
|
|
} from '../shared/document-parsing-contracts'
|
|
import type { SettingsWarning } from '../shared/settings-warning-contracts'
|
|
import {
|
|
decryptSettingsCredential, encryptedSettingsCredentialSchema,
|
|
encryptSettingsCredential, type SettingsCredentialCipher
|
|
} from './settings-credential-cipher'
|
|
import {
|
|
assertSupportedSettingsVersion,
|
|
isolateCorruptSettingsFile,
|
|
isMissingFileError,
|
|
UnsupportedSettingsVersionError,
|
|
writeJsonFileAtomically
|
|
} from './settings-file-utils'
|
|
|
|
const CURRENT_SETTINGS_VERSION = 4
|
|
|
|
const storedDocumentParsingSettingsSchema =
|
|
documentParsingSettingsSchema
|
|
.extend({
|
|
version: z.literal(CURRENT_SETTINGS_VERSION),
|
|
credential: encryptedSettingsCredentialSchema.optional()
|
|
})
|
|
.strict()
|
|
|
|
type StoredDocumentParsingSettings = z.infer<
|
|
typeof storedDocumentParsingSettingsSchema
|
|
>
|
|
|
|
const legacyVersionTwoSettingsSchema = z
|
|
.object({
|
|
version: z.literal(2),
|
|
chatWorkflow: z.enum(['auto', 'fast-text', 'high-fidelity']),
|
|
knowledgeWorkflow: z.enum([
|
|
'complete-index',
|
|
'fast-index',
|
|
'high-fidelity'
|
|
]),
|
|
pdfOcrMode: z.enum(['auto', 'always', 'disabled']),
|
|
ocrProvider: z.literal('local'),
|
|
localOcrEnabled: z.boolean(),
|
|
localOcrModelId: z
|
|
.string()
|
|
.min(1)
|
|
.max(96)
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
|
maximumPages: z.number().int().min(1).max(500),
|
|
ocrConcurrency: z.number().int().min(1).max(4),
|
|
pageTimeoutSeconds: z.number().int().min(10).max(300)
|
|
})
|
|
.strict()
|
|
|
|
const legacyVersionOneSettingsSchema =
|
|
legacyVersionTwoSettingsSchema
|
|
.omit({ version: true, ocrProvider: true })
|
|
.extend({
|
|
version: z.literal(1),
|
|
chatCloudPermission: z.enum(['ask', 'always', 'never']),
|
|
knowledgeCloudPermission: z.enum(['ask', 'always', 'never'])
|
|
})
|
|
.strict()
|
|
|
|
export const defaultDocumentParsingSettings: DocumentParsingSettings = {
|
|
chatWorkflow: 'auto',
|
|
knowledgeWorkflow: 'complete-index',
|
|
localOcrModelId: 'pp-ocrv6-tiny',
|
|
maximumPages: 100,
|
|
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
|
|
}
|
|
}
|
|
|
|
export class DocumentParsingSettingsStore {
|
|
private settings?: StoredDocumentParsingSettings
|
|
private settingsLoad?: Promise<StoredDocumentParsingSettings>
|
|
private warnings: SettingsWarning[] = []
|
|
private updateQueue: Promise<void> = Promise.resolve()
|
|
|
|
constructor(
|
|
private readonly filePath: string,
|
|
private readonly cipher?: SettingsCredentialCipher
|
|
) {}
|
|
|
|
private async isolateCorruptFile(): Promise<void> {
|
|
await isolateCorruptSettingsFile(
|
|
this.filePath,
|
|
'文档解析设置损坏且无法隔离'
|
|
)
|
|
}
|
|
|
|
private loadStored(): Promise<StoredDocumentParsingSettings> {
|
|
if (this.settings) {
|
|
return Promise.resolve(this.settings)
|
|
}
|
|
if (!this.settingsLoad) {
|
|
this.settingsLoad = this.readStored().finally(() => {
|
|
this.settingsLoad = undefined
|
|
})
|
|
}
|
|
return this.settingsLoad
|
|
}
|
|
|
|
private async readStored(): Promise<StoredDocumentParsingSettings> {
|
|
try {
|
|
const contents = await readFile(this.filePath, 'utf8')
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(contents) as unknown
|
|
} catch {
|
|
await this.isolateCorruptFile()
|
|
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
|
this.settings = {
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
...defaultDocumentParsingSettings
|
|
}
|
|
return this.settings
|
|
}
|
|
assertSupportedSettingsVersion(
|
|
parsed,
|
|
CURRENT_SETTINGS_VERSION,
|
|
(version) =>
|
|
`当前 GoodBuddy 不支持文档解析设置版本 ${version},请升级应用后重试`
|
|
)
|
|
const result =
|
|
storedDocumentParsingSettingsSchema.safeParse(parsed)
|
|
if (!result.success) {
|
|
const versionThree = documentParsingSettingsSchema.extend({ version: z.literal(3) }).safeParse(parsed)
|
|
if (versionThree.success) {
|
|
this.settings = { ...versionThree.data, version: CURRENT_SETTINGS_VERSION }
|
|
return this.settings
|
|
}
|
|
const versionTwo =
|
|
legacyVersionTwoSettingsSchema.safeParse(parsed)
|
|
if (versionTwo.success) {
|
|
this.settings = migrateLegacySettings(versionTwo.data)
|
|
return this.settings
|
|
}
|
|
const versionOne =
|
|
legacyVersionOneSettingsSchema.safeParse(parsed)
|
|
if (versionOne.success) {
|
|
const {
|
|
chatCloudPermission: _chatCloudPermission,
|
|
knowledgeCloudPermission: _knowledgeCloudPermission,
|
|
...legacy
|
|
} = versionOne.data
|
|
void _chatCloudPermission
|
|
void _knowledgeCloudPermission
|
|
this.settings = migrateLegacySettings({
|
|
...legacy,
|
|
version: 2,
|
|
ocrProvider: 'local',
|
|
})
|
|
return this.settings
|
|
}
|
|
await this.isolateCorruptFile()
|
|
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
|
this.settings = {
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
...defaultDocumentParsingSettings
|
|
}
|
|
return this.settings
|
|
}
|
|
this.settings = result.data
|
|
} catch (error) {
|
|
if (error instanceof UnsupportedSettingsVersionError) {
|
|
throw error
|
|
}
|
|
if (!isMissingFileError(error)) {
|
|
throw new Error('无法读取文档解析设置', { cause: error })
|
|
}
|
|
this.settings = {
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
...defaultDocumentParsingSettings
|
|
}
|
|
}
|
|
return this.settings
|
|
}
|
|
|
|
async get(): Promise<DocumentParsingSettings> {
|
|
const { version: _version, credential: _credential, ...settings } = await this.loadStored()
|
|
void _version
|
|
void _credential
|
|
return documentParsingSettingsSchema.parse(settings)
|
|
}
|
|
|
|
getWarnings(): readonly SettingsWarning[] {
|
|
return this.warnings
|
|
}
|
|
|
|
async credentialConfigured(): Promise<boolean> {
|
|
return Boolean((await this.loadStored()).credential)
|
|
}
|
|
|
|
async getApiKey(): Promise<string | undefined> {
|
|
const { credential } = await this.loadStored()
|
|
return this.decryptCredential(credential)
|
|
}
|
|
|
|
private decryptCredential(credential: StoredDocumentParsingSettings['credential']): string | undefined {
|
|
if (!credential) return undefined
|
|
if (!this.cipher?.isAvailable()) throw new Error('OCR 凭据加密存储不可用')
|
|
return z.string().min(1).parse(decryptSettingsCredential(this.cipher, credential))
|
|
}
|
|
|
|
async forOperation(): Promise<{ settings: DocumentParsingSettings; apiKey: () => string | undefined }> {
|
|
const stored = await this.loadStored()
|
|
const { version: _version, credential: _credential, ...settings } = stored
|
|
void _version
|
|
void _credential
|
|
return { settings: documentParsingSettingsSchema.parse(settings), apiKey: () => this.decryptCredential(stored.credential) }
|
|
}
|
|
|
|
update(input: unknown): Promise<DocumentParsingSettings> {
|
|
const operation = this.updateQueue.then(async () => {
|
|
const { apiKey, clearApiKey, ...updates } = documentParsingSettingsUpdateSchema.parse(input)
|
|
let credential = (await this.loadStored()).credential
|
|
if (clearApiKey) credential = undefined
|
|
if (apiKey) {
|
|
if (!this.cipher?.isAvailable()) throw new Error('OCR 凭据加密存储不可用')
|
|
credential = encryptSettingsCredential(this.cipher, apiKey)
|
|
}
|
|
const next: StoredDocumentParsingSettings = {
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
...updates,
|
|
...(credential ? { credential } : {})
|
|
}
|
|
await writeJsonFileAtomically(this.filePath, next)
|
|
this.settings = next
|
|
this.warnings = []
|
|
return this.get()
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation
|
|
}
|
|
}
|