feat: add persistent document handling and protect note drafts

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.
This commit is contained in:
mesalogo
2026-09-19 16:51:10 +08:00
parent 17186b0c11
commit 935a8f56bf
95 changed files with 6232 additions and 553 deletions
+8
View File
@@ -1,11 +1,13 @@
import { convert } from 'html-to-text'
import { unzipSync } from 'fflate'
import { extname } from 'node:path'
import type { ParsedDocumentImage } from '../http-document-ocr'
import type {
KnowledgeChunkingSettings,
KnowledgeChunkRole
} from '../../shared/knowledge-contracts'
import {
type DocumentParsingSettings,
maximumDocumentExtractedCharacters,
maximumPdfPageCount
} from '../../shared/document-parsing-contracts'
@@ -16,17 +18,23 @@ export type ParsedSection = {
method?: 'native' | 'ocr' | 'converted' | 'vision'
confidence?: number
pageNumber?: number
sourcePages?: number[]
headingPath?: string[]
blockKind?: DocumentBlockKind
}
export type ParsedDocument = {
parsingSettings?: DocumentParsingSettings
parsingDurationMs?: number
title: string
sourceFormat: string
content: string
sections: ParsedSection[]
warnings: string[]
pageCount?: number
images?: ParsedDocumentImage[]
missingImages?: Array<{ pageNumber: number; key: string; reason: string }>
restructure?: { changed: boolean; sourcePages: number[] }
}
export type DocumentChunk = {
@@ -104,7 +104,7 @@ it('rolls metadata back with binding failures and rejects concurrent duplicate c
try {
await expect(service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: first.knowledgeBaseId, name: 'Changed' })).rejects.toThrow('binding failed')
expect(service.database.getKnowledgeBase(first.knowledgeBaseId)?.name).toBe('Handbook')
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 12 })
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 13 })
} finally { inspection.close() }
const outcomes = await Promise.allSettled([service.external.saveBinding(bindingInput(saved.id, 'new')), service.external.saveBinding(bindingInput(saved.id, 'new'))])
expect(outcomes.filter(item => item.status === 'fulfilled')).toHaveLength(1)
+10 -9
View File
@@ -86,7 +86,7 @@ describe('KnowledgeDatabase', () => {
const inspection = new DatabaseSync(path)
expect(
inspection.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 12 })
).toEqual({ user_version: 13 })
expect(
inspection
.prepare('SELECT version FROM schema_migrations ORDER BY version')
@@ -103,7 +103,8 @@ describe('KnowledgeDatabase', () => {
{ version: 9 },
{ version: 10 },
{ version: 11 },
{ version: 12 }
{ version: 12 },
{ version: 13 }
])
inspection.close()
@@ -144,14 +145,14 @@ describe('KnowledgeDatabase', () => {
expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1)
const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: 12
user_version: 13
})
inspection.close()
})
it.each([
['migration version', 'INSERT INTO schema_migrations VALUES (13, ?)', true],
['user version', 'PRAGMA user_version = 13', false]
['migration version', 'INSERT INTO schema_migrations VALUES (14, ?)', true],
['user version', 'PRAGMA user_version = 14', false]
])('rejects a future %s without downgrading it', async (
_label,
statement,
@@ -169,18 +170,18 @@ describe('KnowledgeDatabase', () => {
const unsupported = new KnowledgeDatabase(path)
expect(() => unsupported.initialize()).toThrow(
'newer than supported version 12'
'newer than supported version 13'
)
const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: hasParameter ? 12 : 13
user_version: hasParameter ? 13 : 14
})
expect(
inspection
.prepare('SELECT MAX(version) AS version FROM schema_migrations')
.get()
).toEqual({ version: hasParameter ? 13 : 12 })
).toEqual({ version: hasParameter ? 14 : 13 })
inspection.close()
})
@@ -330,7 +331,7 @@ describe('KnowledgeDatabase', () => {
upgraded.initialize()
const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: 12
user_version: 13
})
expect(
inspection
+10 -1
View File
@@ -81,7 +81,7 @@ import type {
VectorSearchOptions
} from './types'
const DATABASE_VERSION = 12
const DATABASE_VERSION = 13
const MAX_ID_LENGTH = 128
const MAX_NAME_LENGTH = 512
const MAX_LOCATION_LENGTH = 8192
@@ -1952,6 +1952,11 @@ export class KnowledgeDatabase {
return this.requiredDocument(id)
}
getDocumentByParsedResultId(id: string): Document | undefined {
const row = this.requireDatabase().prepare("SELECT * FROM documents WHERE json_extract(metadata, '$.parsedResultId') = ?").get(id)
return row ? mapDocument(row) : undefined
}
getDocument(id: string): Document | undefined {
const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH)
const row = this.requireDatabase()
@@ -4622,6 +4627,10 @@ export class KnowledgeDatabase {
database.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
.run(12, new Date().toISOString())
}
if (currentVersion < 13) {
database.exec("CREATE INDEX IF NOT EXISTS documents_parsed_result ON documents(json_extract(metadata, '$.parsedResultId'))")
database.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(13, new Date().toISOString())
}
database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`)
database.exec('COMMIT')
} catch (error) {
+89 -19
View File
@@ -13,6 +13,7 @@ import { watch, type FSWatcher } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import {
basename,
dirname,
extname,
join,
relative,
@@ -25,6 +26,8 @@ import {
supportedDocumentExtensions,
type ParsedDocument
} from './document-parser'
import type { DocumentResultStorage } from '../document-result-storage'
import { parsedCompleteness } from '../document-result-storage'
import {
knowledgeChunkDeleteInputSchema,
knowledgeChunksListInputSchema,
@@ -146,6 +149,7 @@ export type KnowledgeSnapshot = {
}
export type KnowledgeServiceOptions = {
documentResults?: DocumentResultStorage
credentialCipher?: import('../settings-credential-cipher').SettingsCredentialCipher
externalFetcher?: typeof fetch
databasePath: string
@@ -170,6 +174,7 @@ const maximumFilesPerSource = 2_000
const maximumEmbeddingChunksPerBatch = 32
type PreparedDocumentPublication = {
signal?: AbortSignal
input: Parameters<KnowledgeDatabase['publishDocument']>[0]
chunks: ReplaceChunkInput[]
graph?: GraphExtractionResult
@@ -215,6 +220,7 @@ export class KnowledgeService {
readonly external: ExternalKnowledgeService
readonly database: KnowledgeDatabase
private readonly managedRoot: string
private readonly documentResults?: DocumentResultStorage
private readonly extractStructured?: ExtractStructured
private readonly urlImporter: UrlImporter
private readonly documentParser: NonNullable<
@@ -262,6 +268,12 @@ export class KnowledgeService {
this.database = new KnowledgeDatabase(options.databasePath)
this.external = new ExternalKnowledgeService(this.database, options.credentialCipher, options.externalFetcher)
this.managedRoot = resolve(options.managedRoot)
this.documentResults = options.documentResults
this.documentResults?.setPersistentLookup((id) => {
const document = this.database.getDocumentByParsedResultId(id)
if (!document?.sourceLocation) return undefined
return { directory: this.resultDirectory(document.knowledgeBaseId, document.id, id), original: document.sourceLocation }
})
this.extractStructured = options.extractStructured
this.urlImporter = options.urlImporter ?? new UrlImporter()
this.documentParser =
@@ -285,6 +297,7 @@ export class KnowledgeService {
async initialize(): Promise<void> {
await mkdir(this.managedRoot, { recursive: true })
this.database.initialize()
await this.reconcileDocumentResults()
for (const library of this.database.listKnowledgeBases()) {
for (const source of this.database.listSourcesForSnapshot(library.id)) {
if (
@@ -298,8 +311,8 @@ export class KnowledgeService {
}
}
async dispose(): Promise<void> {
this.external.dispose()
beginShutdown(): void {
if (this.lifecycleController.signal.aborted) return
this.lifecycleController.abort(
new Error('Knowledge service is shutting down')
)
@@ -310,6 +323,11 @@ export class KnowledgeService {
for (const controller of this.libraryRebuildControllers.values()) {
controller.abort(new Error('Knowledge rebuild cancelled during shutdown'))
}
}
async dispose(): Promise<void> {
this.external.dispose()
this.beginShutdown()
for (const timer of this.syncTimers.values()) {
clearTimeout(timer)
}
@@ -742,7 +760,9 @@ export class KnowledgeService {
): Promise<PreparedDocumentPublication> {
signal?.throwIfAborted()
const documentId = input.id ?? randomUUID()
const chunks = this.createDocumentChunks(parsed, library)
const imagesOnly = parsedCompleteness(parsed) === 'images-only'
if (imagesOnly && this.database.getDocument(documentId)?.metadata.status === 'ready') throw new Error('未提取到可索引文字,保留上次正文与索引')
const chunks = imagesOnly ? [] : this.createDocumentChunks(parsed, library)
let embeddingReplacement: PreparedEmbeddingReplacement | undefined
let embeddingFailure: PreparedDocumentPublication['embeddingFailure']
const embeddingProvider = this.embeddingProvider
@@ -754,10 +774,10 @@ export class KnowledgeService {
scope: 'document',
kind: 'embedding'
})
if (!embeddingProvider) {
if (!embeddingProvider || imagesOnly) {
this.updateKnowledgeTask(embeddingTask.id, {
status: 'skipped',
message: '未启用向量化'
message: imagesOnly ? '无可索引文字,不生成向量' : '未启用向量化'
})
} else {
this.updateKnowledgeTask(embeddingTask.id, {
@@ -768,7 +788,7 @@ export class KnowledgeService {
})
}
try {
embeddingReplacement = await this.prepareDocumentEmbeddings(
embeddingReplacement = imagesOnly ? undefined : await this.prepareDocumentEmbeddings(
documentId,
chunks,
signal
@@ -810,7 +830,7 @@ export class KnowledgeService {
})
try {
signal?.throwIfAborted()
if (library.graphEnabled && library.graphStrategy !== 'ask') {
if (!imagesOnly && library.graphEnabled && library.graphStrategy !== 'ask') {
this.updateKnowledgeTask(graphTask.id, {
status: 'running',
stage: 'graph',
@@ -827,7 +847,7 @@ export class KnowledgeService {
} else {
this.updateKnowledgeTask(graphTask.id, {
status: 'skipped',
message: library.graphEnabled
message: imagesOnly ? '无可索引文字,不抽取知识图谱' : library.graphEnabled
? '按需询问策略不自动抽取'
: '知识图谱未启用'
})
@@ -836,15 +856,7 @@ export class KnowledgeService {
} catch (error) {
if (!signal?.aborted && options.allowGraphFailure) {
this.failKnowledgeTask(graphTask.id, error)
return {
input: { ...input, id: documentId },
chunks,
embeddingReplacement,
embeddingFailure,
embeddingTaskId: embeddingTask.id,
graphTaskId: graphTask.id
}
}
} else {
if (embeddingReplacement) {
this.database.discardDocumentEmbeddingReplacement(
embeddingReplacement.replacementId
@@ -884,9 +896,28 @@ export class KnowledgeService {
this.failKnowledgeTask(graphTask.id, error)
}
throw error
}
}
let metadata: PreparedDocumentPublication['input']['metadata'] = imagesOnly ? { ...input.metadata, status: 'failed', error: '仅图片,无可索引文字;可查看并选择图片' } : { ...input.metadata }
if (!imagesOnly) delete metadata.error
if (this.documentResults && parsed.parsingSettings && input.sourceLocation && !/^https?:/iu.test(input.sourceLocation)) {
let resultId: string | undefined
try {
signal?.throwIfAborted()
const result = await this.documentResults.save(basename(input.sourceLocation), undefined, parsed, parsed.parsingSettings, parsed.parsingDurationMs ?? 0, input.sourceLocation, signal)
resultId = result.id
await this.documentResults.move(result.id, this.resultDirectory(library.id, documentId, result.id))
signal?.throwIfAborted()
metadata = { ...metadata, parsedResultId: result.id }
} catch (error) {
if (resultId) await this.documentResults.release(resultId)
if (embeddingReplacement) this.database.discardDocumentEmbeddingReplacement(embeddingReplacement.replacementId)
throw error
}
}
return {
input: { ...input, id: documentId },
signal,
input: { ...input, id: documentId, metadata },
chunks,
graph,
embeddingReplacement,
@@ -900,7 +931,10 @@ export class KnowledgeService {
library: KnowledgeBase,
prepared: PreparedDocumentPublication
): Document {
const previous = prepared.input.id ? this.database.getDocument(prepared.input.id) : undefined
let published = false
try {
prepared.signal?.throwIfAborted()
const document = this.database.publishDocument(
prepared.input,
prepared.chunks,
@@ -914,6 +948,8 @@ export class KnowledgeService {
: undefined
}
)
published = true
if (typeof document.metadata.parsedResultId === 'string') this.documentResults?.detach(document.metadata.parsedResultId)
if (prepared.embeddingReplacement) {
this.updateKnowledgeTask(prepared.embeddingTaskId, {
documentId: document.id,
@@ -942,8 +978,15 @@ export class KnowledgeService {
documentName: document.title
})
}
if (typeof previous?.metadata.parsedResultId === 'string' && previous.metadata.parsedResultId !== document.metadata.parsedResultId) {
void rm(this.resultDirectory(previous.knowledgeBaseId, previous.id, previous.metadata.parsedResultId), { recursive: true, force: true }).catch(() => undefined)
}
return document
} catch (error) {
if (published) throw error
if (typeof prepared.input.metadata?.parsedResultId === 'string' && prepared.input.metadata.parsedResultId !== previous?.metadata.parsedResultId) {
void this.documentResults?.release(prepared.input.metadata.parsedResultId).catch(() => undefined)
}
const currentEmbeddingTask =
this.database.getKnowledgeTask(prepared.embeddingTaskId)
if (prepared.embeddingReplacement) {
@@ -1067,6 +1110,7 @@ export class KnowledgeService {
await embeddingCoordinator?.waitForCompletion()
this.embeddingIndexCoordinators.delete(id)
const deleted = this.database.deleteKnowledgeBase(id)
if (deleted) await rm(join(dirname(this.managedRoot), 'knowledge-assets', id), { recursive: true, force: true })
if (deleted && library.storageMode === 'managed') {
const path = join(this.managedRoot, id)
if (isPathInside(this.managedRoot, path)) {
@@ -2866,8 +2910,10 @@ export class KnowledgeService {
),
'Knowledge source deleted'
)
const sourceDocuments = this.database.listDocumentsForSource(sourceId)
const removed = this.database.removeSource(sourceId)
if (removed) {
await Promise.all(sourceDocuments.map((document) => rm(join(dirname(this.managedRoot), 'knowledge-assets', library.id, document.id), { recursive: true, force: true })))
this.database.pruneUnreferencedGeneratedGraph(library.id)
}
if (
@@ -2884,6 +2930,28 @@ export class KnowledgeService {
return removed
}
private resultDirectory(libraryId: string, documentId: string, resultId: string): string {
return join(dirname(this.managedRoot), 'knowledge-assets', libraryId, documentId, resultId)
}
private async reconcileDocumentResults(): Promise<void> {
const root = join(dirname(this.managedRoot), 'knowledge-assets')
const directories = async (path: string): Promise<string[]> => {
try { return (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isDirectory() && /^[0-9a-f-]{36}$/iu.test(entry.name)).map((entry) => entry.name) }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
}
for (const libraryId of await directories(root)) {
for (const documentId of await directories(join(root, libraryId))) {
for (const resultId of await directories(join(root, libraryId, documentId))) {
if (this.documentResults?.isTemporaryResult(resultId)) continue
const document = this.database.getDocument(documentId)
if (document?.knowledgeBaseId === libraryId && document.metadata.parsedResultId === resultId) continue
await rm(this.resultDirectory(libraryId, documentId, resultId), { recursive: true, force: true })
}
}
}
}
private async performSyncSource(
sourceId: string,
retryOfTaskId?: string
@@ -3014,7 +3082,9 @@ export class KnowledgeService {
document.id,
() => {
this.cancelScheduledEmbeddingReindex(document.id)
return this.database.removeDocument(document.id)
const removed = this.database.removeDocument(document.id)
if (removed) void rm(join(dirname(this.managedRoot), 'knowledge-assets', library.id, document.id), { recursive: true, force: true }).catch(() => undefined)
return removed
},
signal
)