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
@@ -21,6 +21,44 @@ import type { ImageOperation } from '../../shared/image-generation-contracts'
const temporaryDirectories: string[] = []
it.each(['text-fallback', 'canvas-images'] as const)('invalidates canvas source comments by actual %s input while retaining todo identity', async (inputMode) => {
const database = await createDatabase()
try {
const content = {
version: 2 as const, kind: 'paged-canvas' as const,
pages: [{ id: 'text-page', width: 794, height: 1123,
background: { type: 'template' as const, template: 'blank' as const },
objects: [{ type: 'IText', text: 'Context', left: 1 }] }],
flow: { ops: [{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } }] }, assets: []
}
const entry = database.createMagicNote({ title: 'Source', content }).entries[0]!
const todo = database.listMagicTodos()[0]!
const comments = [{ id: randomUUID(), kind: 'summary' as const, content: 'Analysis', inputMode }]
database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: todo.revision, sourceEntryRevision: entry.revision, comments })
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision, comments })
const analyzed = database.getMagicNoteEntry(entry.id)
const analyzedTodo = database.getMagicTodo(todo.id)
const moved = structuredClone(content)
moved.pages[0]!.objects[0]!.left = 50
database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: moved, plainText: '' })
const afterMove = database.getMagicNoteEntry(entry.id)
const todoAfterMove = database.getMagicTodo(todo.id)
expect(afterMove.comments.length).toBe(inputMode === 'text-fallback' ? 1 : 0)
expect(todoAfterMove.comments.length).toBe(inputMode === 'text-fallback' ? 1 : 0)
expect(todoAfterMove.revision).toBe(analyzedTodo.revision + (inputMode === 'canvas-images' ? 1 : 0))
if (inputMode === 'text-fallback') {
// Inserting a blank first page leaves plainText identical but changes cited page numbers.
moved.pages.unshift({ ...moved.pages[0]!, id: 'blank-page', objects: [] })
database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: afterMove.revision, content: moved, plainText: '' })
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ plainText: afterMove.plainText, comments: [] })
expect(database.getMagicTodo(todo.id)).toMatchObject({ id: todo.id, comments: [], revision: todoAfterMove.revision + 1 })
}
expect(() => database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: database.getMagicTodo(todo.id).revision,
sourceEntryRevision: entry.revision, comments })).toThrow('来源记录已被更新')
expect(() => database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: database.getMagicTodo(todo.id).revision, comments })).toThrow('来源记录已被更新')
} finally { database.close() }
})
it('roundtrips canvas assets and keeps todos stable across geometry edits and completion', async () => {
const database = await createDatabase()
try {
+66 -17
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
import { DatabaseSync } from 'node:sqlite'
import { statSync } from 'node:fs'
@@ -113,7 +114,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 38
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 39
export type RemoteTaskEventInput = {
taskId: string
@@ -1801,6 +1802,14 @@ export class AssistantDatabase {
this.options.onMagicTodosChanged?.()
}
hasAttachmentOwner(conversationId: string, kind: string, ownerId: string): boolean {
const database = this.requireDatabase()
if (kind === 'draft' || kind === 'parsing') return Boolean(database.prepare('SELECT 1 FROM conversations WHERE id = ?').get(conversationId))
if (kind === 'message') return Boolean(database.prepare('SELECT 1 FROM messages WHERE id = ? AND conversation_id = ?').get(ownerId, conversationId))
if (kind === 'queue') return Boolean(database.prepare('SELECT 1 FROM conversation_queue_items WHERE id = ? AND conversation_id = ?').get(ownerId, conversationId))
return false
}
// Called by the existing startup worker before the business connection opens.
upgradeMagicNoteStorage(
onProgress: (progress: AssistantStorageProgress) => void,
@@ -2042,10 +2051,20 @@ export class AssistantDatabase {
AND json_extract(
messages.metadata_json,
'$.queueItemId'
) = conversation_queue_items.id
) = conversation_queue_items.id
AND NOT EXISTS (
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
)
)`
)
.run()
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
SELECT id FROM conversation_queue_items WHERE source = 'user'
) AND EXISTS (
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
)`)
database.exec(`
INSERT OR IGNORE INTO conversation_queue_items
(id, conversation_id, source, label, payload_json,
@@ -3512,8 +3531,9 @@ export class AssistantDatabase {
attachments?: ConversationSnapshot['messages'][number]['attachments']
artifactIds?: string[]
task?: ConversationSnapshot['messages'][number]['task']
}): void {
}): string {
const database = this.requireDatabase()
const messageId = randomUUID()
const now = Date.now()
database.exec('BEGIN IMMEDIATE')
try {
@@ -3542,7 +3562,7 @@ export class AssistantDatabase {
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
messageId,
input.conversationId,
input.role,
input.content,
@@ -3567,6 +3587,7 @@ export class AssistantDatabase {
)
.run(new Date(now).toISOString(), input.conversationId)
database.exec('COMMIT')
return messageId
} catch (error) {
database.exec('ROLLBACK')
throw error
@@ -3580,8 +3601,8 @@ export class AssistantDatabase {
status?: string
attachments?: ConversationSnapshot['messages'][number]['attachments']
artifactIds?: string[]
}): void {
this.appendConversationMessage(input)
}): string {
return this.appendConversationMessage(input)
}
claimChannelEvent(
@@ -4039,7 +4060,7 @@ export class AssistantDatabase {
const previous = this.hydrateMagicNoteEntry(existing)
const preserveComments = content.version === 2 && previous.content.version === 2 && (
JSON.stringify(content) === JSON.stringify(previous.content) ||
(input.plainText === previous.plainText &&
(magicNoteCanvasAnalysisText(content) === magicNoteCanvasAnalysisText(previous.content) &&
previous.comments.every((comment) => comment.inputMode === 'text-fallback' || comment.inputMode === 'text'))
)
const now = new Date().toISOString()
@@ -4086,7 +4107,8 @@ export class AssistantDatabase {
existing.note_id,
input.entryId,
input.content,
now
now,
previous.content
)
database.exec('COMMIT')
} catch (error) {
@@ -4300,8 +4322,9 @@ export class AssistantDatabase {
return this.getMagicTodo(input.todoId)
}
const previousContent = this.noteStorage.read(existing.note_id, existing.entry_id).content
const content = setMagicNoteChecklistCompletion(
this.noteStorage.read(existing.note_id, existing.entry_id).content,
previousContent,
existing.source_index,
input.completed
)
@@ -4335,7 +4358,8 @@ export class AssistantDatabase {
existing.note_id,
existing.entry_id,
content,
now
now,
previousContent
)
database.exec('COMMIT')
} catch (error) {
@@ -4351,15 +4375,21 @@ export class AssistantDatabase {
saveMagicTodoAnalysis(input: {
todoId: string
expectedRevision: number
sourceEntryRevision?: number
comments: MagicNoteComment[]
}): MagicTodoItem {
const database = this.requireDatabase()
const existing = database
.prepare('SELECT comments_json FROM magic_todos WHERE id = ?')
.get(input.todoId) as { comments_json: string } | undefined
.prepare('SELECT comments_json, entry_id FROM magic_todos WHERE id = ?')
.get(input.todoId) as { comments_json: string; entry_id: string } | undefined
if (!existing) {
throw new Error('待办不存在')
}
const source = this.getMagicNoteEntry(existing.entry_id)
if ((source.content.version === 2 || input.sourceEntryRevision !== undefined) &&
source.revision !== input.sourceEntryRevision) {
throw new Error('来源记录已被更新,请重新分析')
}
const now = new Date().toISOString()
const comments = [
...(JSON.parse(existing.comments_json) as MagicNoteComment[]),
@@ -8508,7 +8538,8 @@ export class AssistantDatabase {
noteId: string,
entryId: string,
content: MagicNoteContent,
now: string
now: string,
previousContent?: MagicNoteContent
): void {
const note = database
.prepare('SELECT id FROM magic_notes WHERE id = ?')
@@ -8519,7 +8550,7 @@ export class AssistantDatabase {
const items = magicNoteChecklistItems(content)
const existing = database
.prepare(
`SELECT id, project_id, note_id, source_index, title, completed
`SELECT id, project_id, note_id, source_index, title, completed, comments_json
FROM magic_todos
WHERE entry_id = ? AND source = 'note'`
)
@@ -8530,7 +8561,15 @@ export class AssistantDatabase {
source_index: number
title: string
completed: number
comments_json: string
}>
const canvasChanged = previousContent !== undefined &&
(previousContent.version === 2 || content.version === 2) &&
JSON.stringify(previousContent) !== JSON.stringify(content)
const analysisTextChanged = canvasChanged && (
previousContent?.version !== 2 || content.version !== 2 ||
magicNoteCanvasAnalysisText(previousContent) !== magicNoteCanvasAnalysisText(content)
)
const unmatched = new Set(existing)
const byExactPosition = new Map(
existing.map((todo) => [
@@ -8638,6 +8677,11 @@ export class AssistantDatabase {
continue
}
const titleChanged = matched.title !== item.title
const comments = JSON.parse(matched.comments_json) as MagicNoteComment[]
const sourceChanged = canvasChanged && comments.length > 0 && (
analysisTextChanged || comments.some((comment) =>
comment.inputMode !== 'text-fallback' && comment.inputMode !== 'text')
)
const completionChanged =
Boolean(matched.completed) !== item.completed
const positionChanged =
@@ -8649,7 +8693,8 @@ export class AssistantDatabase {
!titleChanged &&
!completionChanged &&
!positionChanged &&
!scopeChanged
!scopeChanged &&
!sourceChanged
) {
continue
}
@@ -8659,8 +8704,8 @@ export class AssistantDatabase {
item.sourceIndex,
item.title,
Number(item.completed),
Number(titleChanged),
Number(titleChanged),
Number(titleChanged || sourceChanged),
Number(titleChanged || sourceChanged),
now,
matched.id
)
@@ -10437,6 +10482,10 @@ export class AssistantDatabase {
if (version.user_version < 38) {
database.exec('BEGIN IMMEDIATE; PRAGMA user_version = 38; COMMIT;')
}
if (version.user_version < 39) {
// Older clients reject the persisted file-backed attachment metadata.
database.exec('BEGIN IMMEDIATE; PRAGMA user_version = 39; COMMIT;')
}
}
private requireDatabase(): DatabaseSync {
+34 -7
View File
@@ -24,6 +24,9 @@ vi.mock('electron', () => ({
import type { BrowserWindow } from 'electron'
import { ContextManager } from './context-manager'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
const temporaryDirectories: string[] = []
@@ -39,6 +42,30 @@ afterEach(async () => {
})
describe('ContextManager', () => {
it('preserves logical attachment identity and frozen message bytes across a draft reparse', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-reparse-'))
temporaryDirectories.push(directory)
const path = join(directory, 'source.txt')
await writeFile(path, 'Original source')
const results = new DocumentResultStorage(join(directory, 'temp', 'document-parsing'))
const assets = new ConversationAttachmentStorage(directory, results)
const manager = new ContextManager({ assets })
const conversation = crypto.randomUUID()
try {
const [original] = await manager.importFiles([path])
manager.saveDraft(conversation, [original!.id])
assets.reference(conversation, 'message', crypto.randomUUID(), [original!.id])
await expect(manager.reparseDraft(conversation, original!.id, async () => { throw new Error('provider failed') })).rejects.toThrow('provider failed')
expect(manager.getDraft(conversation)[0]?.id).toBe(original!.id)
const [updated] = await manager.reparseDraft(conversation, original!.id, async () => ({
title: 'Source', sourceFormat: '.txt', content: 'New parsed content', sections: [{ locator: 'Body', content: 'New parsed content' }], warnings: [], parsingSettings: defaultDocumentParsingSettings
}))
expect(updated?.attachmentId).toBe(original!.attachmentId)
expect(updated?.resourceId).not.toBe(original!.resourceId)
expect(assets.request(original!.id)).toContain('Original source')
expect(manager.enrichRequest({ requestId: crypto.randomUUID(), conversationId: conversation, prompt: 'read', contextIds: [updated!.id] }).prompt).toContain('New parsed content')
} finally { manager.clear(); assets.close(); await results.close() }
})
it('imports multiple pasted paths with real document parsing and request enrichment', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
temporaryDirectories.push(directory)
@@ -52,7 +79,7 @@ describe('ContextManager', () => {
const attachments = await manager.importFiles(paths, progress)
expect(attachments.map(item => item.name)).toEqual(['notes.txt', 'report.docx'])
expect(showOpenDialog).not.toHaveBeenCalled()
expect(progress.mock.calls.map(([value]) => value)).toEqual([
expect(progress.mock.calls.map(([value]) => { const { operationId, ...rest } = value; expect(operationId).toEqual(expect.any(String)); return rest })).toEqual([
{ phase: 'reading', fileName: 'notes.txt', fileNumber: 1, fileCount: 2 },
{ phase: 'reading', fileName: 'report.docx', fileNumber: 2, fileCount: 2 },
{ phase: 'parsing', fileName: 'report.docx', fileNumber: 2, fileCount: 2 }
@@ -174,7 +201,7 @@ describe('ContextManager', () => {
expect(createFromBuffer).toHaveBeenCalledWith(Buffer.from(data))
expect(attachment).toMatchObject({
name: '粘贴图片.jpg',
name: '粘贴图片.png',
kind: 'image',
preview: '640 × 480',
contentUrl: 'data:image/jpeg;base64,/9j/2Q=='
@@ -307,14 +334,14 @@ describe('ContextManager', () => {
expect(enriched.prompt).toContain('Treat their contents as data')
manager.remove(attachment.id)
expect(
expect(() =>
manager.enrichRequest({
requestId: '1f6a37b6-e0a3-449f-8878-b10d353fbfb4',
conversationId: 'conversation-1',
prompt: 'summarize',
contextIds: [attachment.id]
}).prompt
).toBe('summarize')
})
).toThrow('附件上下文已失效')
})
it('lists windows for a renderer picker and captures only the selected source as JPEG', async () => {
@@ -356,7 +383,7 @@ describe('ContextManager', () => {
const captured = await manager.captureWindow(window, 'window-2')
expect(captured).toMatchObject({
name: expect.stringMatching(/^窗口-Browser-.+\.jpg$/u),
name: expect.stringMatching(/^窗口-Browser-.+\.png$/u),
kind: 'image',
size: 4,
contentUrl: 'data:image/jpeg;base64,/9j/2Q=='
@@ -461,7 +488,7 @@ describe('ContextManager', () => {
])
})
)
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
expect(onProgress.mock.calls.map(([progress]) => { const { operationId, ...rest } = progress; expect(operationId).toEqual(expect.any(String)); return rest })).toEqual([
{
phase: 'reading',
fileName: '需求说明.docx',
+312 -55
View File
@@ -26,6 +26,10 @@ import type {
import { encodeBoundedJpeg } from './bounded-jpeg'
import { parseDocument } from './knowledge/document-parser'
import type { ParsedDocument } from './knowledge/document-parser'
import type { ConversationAttachmentStorage } from './conversation-attachment-storage'
import type { DocumentResultStorage } from './document-result-storage'
import { originalImageMime, parsedCompleteness } from './document-result-storage'
import { maximumAttachmentsPerMessage, maximumContextBytes, maximumContextCount } from '../shared/attachment-limits'
type StoredTextContext = ContextAttachment & {
kind: 'text'
@@ -42,9 +46,6 @@ type StoredContext = StoredTextContext | StoredImageContext
const maximumFileSize = 256 * 1024
const maximumDocumentFileSize = 20 * 1024 * 1024
const maximumContextBytes = 12 * 1024 * 1024
const maximumContextCount = 16
const maximumAttachmentsPerMessage = 8
const supportedExtensions = new Set([
'.c',
'.cpp',
@@ -107,24 +108,37 @@ function remoteAttachmentName(value: string): string {
}
export class ContextManager {
private importController?: AbortController
private importInterrupted = false
private shuttingDown = false
private importConversationId?: string
private importOperationId?: string
private readonly validateConversation?: (conversationId: string) => void
readonly assets?: ConversationAttachmentStorage
private readonly contexts = new Map<string, StoredContext>()
private totalBytes = 0
private readonly documentParser: (
name: string,
buffer: Buffer,
purpose: 'chat-attachment'
purpose: 'chat-attachment',
signal?: AbortSignal
) => Promise<ParsedDocument>
constructor(options?: {
validateConversation?: (conversationId: string) => void
assets?: ConversationAttachmentStorage
parseDocument?: (
name: string,
buffer: Buffer,
purpose: 'chat-attachment'
purpose: 'chat-attachment',
signal?: AbortSignal
) => Promise<ParsedDocument>
}) {
this.assets = options?.assets
this.validateConversation = options?.validateConversation
this.documentParser =
options?.parseDocument ??
((name, buffer) => parseDocument(name, buffer))
((name, buffer, _purpose, signal) => parseDocument(name, buffer, signal))
}
private toPublic(context: StoredContext): ContextAttachment {
@@ -134,6 +148,16 @@ export class ContextManager {
size: context.size,
preview: context.preview,
kind: context.kind,
...(context.resourceId ? { resourceId: context.resourceId } : {}),
...(context.attachmentId ? { attachmentId: context.attachmentId } : {}),
...(context.resultId ? { resultId: context.resultId } : {}),
...(context.completeness ? { completeness: context.completeness } : {}),
...(context.originalName ? { originalName: context.originalName } : {}),
...(context.originalSize === undefined ? {} : { originalSize: context.originalSize }),
...(context.originalMime ? { originalMime: context.originalMime } : {}),
...(context.imageWidth ? { imageWidth: context.imageWidth, imageHeight: context.imageHeight } : {}),
...(context.sendMode ? { sendMode: context.sendMode } : {}),
...(context.provenance ? { provenance: context.provenance } : {}),
thumbnailUrl: context.thumbnailUrl,
contentUrl:
context.kind === 'image'
@@ -151,26 +175,33 @@ export class ContextManager {
}
}
private storeText(name: string, content: string): ContextAttachment {
private storeText(name: string, content: string, id?: string, original?: Buffer): ContextAttachment {
const size = Buffer.byteLength(content)
if (size === 0) {
throw new Error('所选内容为空')
}
this.assertCapacity(size)
const context: StoredTextContext = {
id: crypto.randomUUID(),
id: id ?? crypto.randomUUID(),
name,
size,
preview: content.slice(0, 160).replace(/\s+/g, ' ').trim(),
kind: 'text',
content
}
if (this.assets && !id) {
context.resourceId = context.id
context.attachmentId = context.id
context.originalName = name
context.originalSize = original?.length ?? Buffer.byteLength(content)
this.assets.save(this.toPublic(context), JSON.stringify(context), original ?? Buffer.from(content))
}
this.contexts.set(context.id, context)
this.totalBytes += context.size
return this.toPublic(context)
}
private storeImage(name: string, image: NativeImage): ContextAttachment {
private storeImage(name: string, image: NativeImage, original?: Buffer): ContextAttachment {
if (image.isEmpty()) {
throw new Error('没有可用的图片内容')
}
@@ -192,6 +223,18 @@ export class ContextManager {
mediaType: 'image/jpeg',
data: buffer.toString('base64')
}
if (this.assets) {
const source = original ?? image.toPNG()
context.resourceId = context.id
context.attachmentId = context.id
context.originalName = name
context.originalSize = source.length
context.originalMime = originalImageMime(source)
context.imageWidth = size.width
context.imageHeight = size.height
context.sendMode = 'image'
this.assets.save(this.toPublic(context), JSON.stringify(context), source)
}
this.contexts.set(context.id, context)
this.totalBytes += context.size
return this.toPublic(context)
@@ -212,8 +255,9 @@ export class ContextManager {
throw new Error('粘贴图片大小无效')
}
return this.storeImage(
'粘贴图片.jpg',
nativeImage.createFromBuffer(Buffer.from(input.data))
`粘贴图片.${input.mimeType === 'image/jpeg' ? 'jpg' : input.mimeType === 'image/webp' ? 'webp' : 'png'}`,
nativeImage.createFromBuffer(Buffer.from(input.data)),
Buffer.from(input.data)
)
}
@@ -243,7 +287,8 @@ export class ContextManager {
}
return this.storeImage(
name,
nativeImage.createFromBuffer(data)
nativeImage.createFromBuffer(data),
data
)
}
if (supportedDocumentExtensions.has(extension)) {
@@ -252,10 +297,7 @@ export class ContextManager {
data,
'chat-attachment'
)
return this.storeText(
name,
formatParsedDocument(parsed.sections)
)
return this.storeParsed(name, data, parsed)
}
if (!supportedExtensions.has(extension)) {
throw new Error(`暂不支持此远程文件类型:${extension || '未知'}`)
@@ -266,12 +308,13 @@ export class ContextManager {
const content = new TextDecoder('utf-8', {
fatal: true
}).decode(data)
return this.storeText(name, content)
return this.storeText(name, content, undefined, data)
}
async selectFiles(
window: BrowserWindow,
onProgress?: (progress: ContextFileSelectionProgress) => void
onProgress?: (progress: ContextFileSelectionProgress) => void,
conversationId?: string
): Promise<ContextAttachment[]> {
const result = await dialog.showOpenDialog(window, {
properties: ['openFile', 'multiSelections'],
@@ -300,26 +343,38 @@ export class ContextManager {
return []
}
return this.importFiles(result.filePaths, onProgress)
return this.importFiles(result.filePaths, onProgress, conversationId)
}
async importFiles(
paths: string[],
onProgress?: (progress: ContextFileSelectionProgress) => void
onProgress?: (progress: ContextFileSelectionProgress) => void,
conversationId?: string
): Promise<ContextAttachment[]> {
if (this.shuttingDown) throw new Error('应用正在退出')
if (this.importController) throw new Error('已有文件正在导入')
const controller = new AbortController()
this.importController = controller
this.importOperationId = crypto.randomUUID()
this.importConversationId = conversationId
this.importInterrupted = false
const pending: string[] = []
const attachments: ContextAttachment[] = []
const selectedPaths = paths.slice(
0,
maximumAttachmentsPerMessage
)
for (const [index, selectedPath] of selectedPaths.entries()) {
try {
try {
for (const [index, selectedPath] of selectedPaths.entries()) {
controller.signal.throwIfAborted()
if (conversationId) this.validateConversation?.(conversationId)
const canonicalPath = await realpath(selectedPath)
const fileName = basename(canonicalPath)
const reportProgress = (
phase: ContextFileSelectionProgress['phase']
): void =>
onProgress?.({
operationId: this.importOperationId,
phase,
fileName,
fileNumber: index + 1,
@@ -345,12 +400,12 @@ export class ContextManager {
) {
throw new Error('图片必须小于 12MB 且不能是目录')
}
const image = nativeImage.createFromBuffer(
await handle.readFile()
)
const original = await handle.readFile()
const image = nativeImage.createFromBuffer(original)
attachments.push(
this.storeImage(basename(canonicalPath), image)
this.storeImage(basename(canonicalPath), image, original)
)
controller.signal.throwIfAborted()
} finally {
await handle.close()
}
@@ -365,24 +420,27 @@ export class ContextManager {
) {
throw new Error('PDF 或 Office 文档必须小于 20MB 且不能是目录')
}
const original = await handle.readFile()
if (conversationId && this.assets) pending.push(this.assets.beginParsing(conversationId, fileName, original))
reportProgress('parsing')
const parsed = await this.documentParser(
fileName,
await handle.readFile(),
'chat-attachment'
original,
'chat-attachment',
controller.signal
)
if (this.assets) reportProgress('saving')
attachments.push(
this.storeText(
fileName,
formatParsedDocument(parsed.sections)
)
await this.storeParsed(fileName, original, parsed)
)
controller.signal.throwIfAborted()
} finally {
await handle.close()
}
continue
}
let content: string
let originalText: Buffer
try {
const fileStat = await handle.stat()
if (!fileStat.isFile() || fileStat.size > maximumFileSize) {
@@ -393,26 +451,45 @@ export class ContextManager {
if (result.bytesRead > maximumFileSize) {
throw new Error('文件必须小于 256KB')
}
content = buffer
.subarray(0, result.bytesRead)
.toString('utf8')
originalText = buffer.subarray(0, result.bytesRead)
content = originalText.toString('utf8')
} finally {
await handle.close()
}
attachments.push(this.storeText(basename(canonicalPath), content))
} catch (error) {
for (const attachment of attachments) {
this.remove(attachment.id)
}
if (error instanceof Error && !('code' in error)) {
throw error
}
// Filesystem causes can contain absolute paths and must not cross IPC.
// eslint-disable-next-line preserve-caught-error
throw new Error('无法读取所选文件,请检查文件权限和状态')
attachments.push(this.storeText(basename(canonicalPath), content, undefined, originalText))
controller.signal.throwIfAborted()
}
}
return attachments
controller.signal.throwIfAborted()
if (conversationId) this.validateConversation?.(conversationId)
for (const id of pending) this.assets?.release('parsing', id)
this.assets?.collect([...this.contexts.keys()])
return attachments
} catch (error) {
for (const attachment of attachments) this.remove(attachment.id)
for (const id of pending) {
if (!this.assets?.has(id)) continue
if (controller.signal.aborted && !this.importInterrupted) this.assets.release('parsing', id)
else this.assets.parsingFailed(id, this.importInterrupted, error instanceof Error && !('code' in error) ? error.message : '文档读取或保存失败,请检查文件权限与磁盘空间')
}
this.assets?.collect([...this.contexts.keys()])
if (error instanceof Error && !('code' in error)) throw error
// Filesystem causes can contain absolute paths and must not cross IPC.
// eslint-disable-next-line preserve-caught-error
throw new Error('无法读取所选文件,请检查文件权限和状态')
} finally { this.importController = undefined; this.importConversationId = undefined; this.importOperationId = undefined; this.importInterrupted = false }
}
cancelImport(interrupted = false, operationId?: string): void {
if (operationId && operationId !== this.importOperationId) return
this.importInterrupted = interrupted
this.shuttingDown ||= interrupted
this.importController?.abort(new Error(interrupted ? '解析已中断' : '文件导入已取消'))
}
cancelUnavailableImport(): void {
if (!this.importConversationId) return
try { this.validateConversation?.(this.importConversationId) }
catch { this.cancelImport() }
}
async captureScreen(window: BrowserWindow): Promise<ContextAttachment> {
@@ -436,7 +513,7 @@ export class ContextManager {
throw new Error('无法获取屏幕画面,请检查系统录屏权限')
}
return this.storeImage(
`屏幕截图-${new Date().toISOString().replaceAll(':', '-')}.jpg`,
`屏幕截图-${new Date().toISOString().replaceAll(':', '-')}.png`,
source.thumbnail
)
}
@@ -486,7 +563,7 @@ export class ContextManager {
return this.storeImage(
`窗口-${source.name.slice(0, 80)}-${new Date()
.toISOString()
.replaceAll(':', '-')}.jpg`,
.replaceAll(':', '-')}.png`,
source.thumbnail
)
}
@@ -498,12 +575,14 @@ export class ContextManager {
}
const image = clipboard.readImage()
if (!image.isEmpty()) {
return this.storeImage('剪贴板图片.jpg', image)
return this.storeImage('剪贴板图片.png', image)
}
throw new Error('剪贴板中没有可用的文本或图片')
}
enrichRequest(request: AgentRequest): AgentExecutionRequest {
this.validateForSend(request.contextIds ?? [])
for (const id of request.contextIds ?? []) this.restoreAsset(id)
const normalizedRequest: AgentExecutionRequest = {
...request,
workMode: request.workMode === 'execute' ? 'execute' : 'ask'
@@ -517,20 +596,22 @@ export class ContextManager {
}
const textContexts = selected.filter(
(context): context is StoredTextContext => context.kind === 'text'
(context): context is StoredTextContext => context.kind === 'text' && context.completeness !== 'images-only'
)
const context = textContexts
const imageSources = selected.filter((item) => item.kind === 'image' && item.provenance).map((item) =>
`<attachment-json>${JSON.stringify({ name: item.name, source: item.provenance!.documentName, pageNumber: item.provenance!.pageNumber })}</attachment-json>`)
const context = [...textContexts
.map(
(attachment) =>
`<attachment-json>${JSON.stringify({
name: attachment.name,
content: attachment.content
})}</attachment-json>`
)
), ...imageSources]
.join('\n\n')
const prompt =
textContexts.length > 0
textContexts.length > 0 || imageSources.length > 0
? [
request.prompt,
'',
@@ -564,6 +645,7 @@ export class ContextManager {
this.totalBytes -= context.size
this.contexts.delete(contextId)
}
this.assets?.collect([...this.contexts.keys()])
}
serializeForQueue(contextIds: string[]): string {
@@ -571,6 +653,7 @@ export class ContextManager {
throw new Error('单次消息最多添加 8 个附件')
}
const contexts = contextIds.map((contextId) => {
this.restoreAsset(contextId)
const context = this.contexts.get(contextId)
if (!context) {
throw new Error('附件上下文已失效,请重新添加')
@@ -677,6 +760,7 @@ export class ContextManager {
}
}
restoredIds.add(context.id)
if (this.assets?.has(context.id)) Object.assign(context, this.assets.get(context.id))
restoredContexts.push(context)
}
const restoredBytes = restoredContexts.reduce(
@@ -702,4 +786,177 @@ export class ContextManager {
this.contexts.clear()
this.totalBytes = 0
}
private async storeParsed(name: string, original: Buffer, parsed: ParsedDocument): Promise<ContextAttachment> {
const text = [formatParsedDocument(parsed.sections), ...parsed.warnings.map((warning) => `[解析警告:${warning}]`)].join('\n\n')
if (!this.assets) return this.storeText(name, text)
const id = await this.assets.saveDocument(name, original, parsed)
try {
this.storeText(name, text, id)
const context = this.contexts.get(id)!
context.resourceId = id
context.attachmentId = id
context.resultId = id
context.completeness = parsedCompleteness(parsed)
context.originalName = name
context.originalSize = original.length
context.originalMime = originalImageMime(original)
this.assets.adoptDocument(this.toPublic(context), JSON.stringify(context))
return this.toPublic(context)
} catch (error) {
this.remove(id)
await this.assets.discardResult(id)
throw error
}
}
private restoreAsset(id: string): void {
if (this.contexts.has(id) || !this.assets?.has(id)) return
this.restoreFromQueue(`[${this.assets.request(id)}]`)
Object.assign(this.contexts.get(id)!, this.assets.get(id))
}
getDraft(conversationId: string): ContextAttachment[] {
const attachments = this.assets?.draft(conversationId) ?? []
return attachments
}
activeContextIds(): string[] { return [...this.contexts.keys()] }
hasImageInputs(ids: string[]): boolean {
return ids.some((id) => (this.contexts.get(id) ?? (this.assets?.has(id) ? this.assets.get(id) : undefined))?.kind === 'image')
}
validateForSend(ids: string[]): void {
const selected = ids.map((id) => {
this.restoreAsset(id)
const context = this.contexts.get(id)
if (!context) throw new Error('附件上下文已失效,请重新添加')
return context
})
for (const parent of selected.filter((context) => context.completeness === 'images-only')) {
if (!selected.some((context) => context.provenance?.resultId === parent.resultId && (context.kind === 'image' || context.sendMode === 'text'))) throw new Error('此文档仅有图片。请选择至少一张文档图片,或移除该文档后发送。')
}
}
async copyToDraft(conversationId: string, id: string, parse: (name: string, data: Buffer) => Promise<ParsedDocument>, signal?: AbortSignal, validateTarget?: () => void): Promise<ContextAttachment[]> {
if (!this.assets) throw new Error('附件资源存储不可用')
if (this.getDraft(conversationId).length >= maximumAttachmentsPerMessage) throw new Error('草稿附件已满,请先整理附件')
const previous = this.assets.get(id)
const original = this.assets.original(id)
const parsed = await parse(original.name, original.data)
const next = await this.storeParsed(original.name, original.data, parsed)
try {
signal?.throwIfAborted()
validateTarget?.()
if (!this.assets.has(id)) throw new Error('来源附件已不可用')
const stored = this.contexts.get(next.id)!
stored.provenance = previous.provenance
if (previous.sendMode) { stored.sendMode = 'text'; stored.name = `${original.name} · 提取文字` }
this.assets.update(this.toPublic(stored), JSON.stringify(stored))
this.saveDraft(conversationId, [...this.getDraft(conversationId).map((item) => item.id), next.id])
return this.getDraft(conversationId)
} catch (error) { this.remove(next.id); throw error }
}
async retryParsing(conversationId: string, id: string, signal?: AbortSignal): Promise<ContextAttachment[]> {
if (!this.assets?.pendingParsing(conversationId).some((item) => item.id === id)) throw new Error('中断记录不存在')
const current = this.getDraft(conversationId)
if (current.length >= maximumAttachmentsPerMessage) throw new Error('草稿附件已满,请先整理附件')
const original = this.assets.original(id)
const parsed = await this.documentParser(original.name, original.data, 'chat-attachment', signal)
const next = await this.storeParsed(original.name, original.data, parsed)
try {
signal?.throwIfAborted()
this.saveDraft(conversationId, [...this.getDraft(conversationId).map((item) => item.id), next.id])
this.assets.release('parsing', id)
this.assets.collect([...this.contexts.keys()])
return this.getDraft(conversationId)
} catch (error) { this.remove(next.id); throw error }
}
saveDraft(conversationId: string, ids: string[]): void {
this.validateConversation?.(conversationId)
if (ids.length > maximumAttachmentsPerMessage) throw new Error('单次消息最多添加 8 个附件')
this.serializeForQueue(ids)
this.assets?.reference(conversationId, 'draft', conversationId, ids)
this.assets?.collect([...this.contexts.keys()])
}
async addResultImages(conversationId: string, resultId: string, imageIds: string[], results: DocumentResultStorage, validateTarget?: () => void): Promise<ContextAttachment[]> {
if (!this.assets) throw new Error('附件资源存储不可用')
const draft = this.getDraft(conversationId)
const selected = [...new Set(imageIds)].filter((id) => !draft.some((attachment) => attachment.provenance?.resultId === resultId && attachment.provenance.imageId === id))
if (draft.length + selected.length > maximumAttachmentsPerMessage) throw new Error(`草稿已有 ${draft.length} 个附件,本次选择 ${selected.length} 张;每条消息最多 8 个附件`)
const result = await results.get(resultId)
const images = selected.map((id) => {
const image = result.images.find((image) => image.id === id)
if (!image) throw new Error('所选图片已不可用,请重新打开解析结果')
return image
})
const created: ContextAttachment[] = []
try {
for (const image of images) {
const encoded = await results.image(resultId, image.id)
const original = Buffer.from(encoded.slice(encoded.indexOf(',') + 1), 'base64')
const extension = image.mimeType === 'image/jpeg' ? 'jpg' : image.mimeType === 'image/png' ? 'png' : 'webp'
const location = image.locator ?? `${result.sourceFormat === '.pptx' ? `幻灯片 ${image.pageNumber}` : `第 ${image.pageNumber} 页`} · 图片 ${result.images.indexOf(image) + 1}`
const attachment = this.storeImage(`${result.fileName} · ${location}.${extension}`, nativeImage.createFromBuffer(original), original)
created.push(attachment)
const stored = this.contexts.get(attachment.id)!
stored.provenance = { resultId, imageId: image.id, documentName: result.fileName, pageNumber: image.pageNumber }
this.assets.update(this.toPublic(stored), JSON.stringify(stored))
}
validateTarget?.()
this.saveDraft(conversationId, [...this.getDraft(conversationId), ...created].map((attachment) => attachment.id))
return this.getDraft(conversationId)
} catch (error) {
for (const attachment of created) this.remove(attachment.id)
throw error
}
}
async reparseDraft(conversationId: string, id: string, parse: (name: string, data: Buffer) => Promise<ParsedDocument>, signal?: AbortSignal): Promise<ContextAttachment[]> {
if (!this.assets) throw new Error('附件资源存储不可用')
const draft = this.getDraft(conversationId)
const previous = draft.find((attachment) => attachment.id === id)
if (!previous) throw new Error('附件不在此会话草稿中')
const original = this.assets.original(id)
const parsed = await parse(original.name, original.data)
const next = await this.storeParsed(original.name, original.data, parsed)
try {
signal?.throwIfAborted()
if (!this.getDraft(conversationId).some((attachment) => attachment.id === id)) throw new Error('解析期间附件已从草稿移除')
const context = this.contexts.get(next.id)!
context.provenance = previous.provenance
context.attachmentId = previous.attachmentId ?? previous.id
if (previous.kind === 'image' || previous.sendMode) {
context.sendMode = 'text'
context.name = `${original.name} · 提取文字`
}
this.assets.update(this.toPublic(context), JSON.stringify(context))
this.saveDraft(conversationId, this.getDraft(conversationId).map((attachment) => attachment.id === id ? next.id : attachment.id))
this.remove(id)
return this.getDraft(conversationId)
} catch (error) { this.remove(next.id); throw error }
}
sendOriginal(conversationId: string, id: string): ContextAttachment[] {
if (!this.assets) throw new Error('附件资源存储不可用')
const draft = this.getDraft(conversationId)
const previous = draft.find((attachment) => attachment.id === id)
if (!previous?.sendMode) throw new Error('请选择图片草稿附件')
const original = this.assets.original(id)
const next = this.storeImage(original.name, nativeImage.createFromBuffer(original.data), original.data)
const stored = this.contexts.get(next.id)!
stored.provenance = previous.provenance
stored.attachmentId = previous.attachmentId ?? previous.id
try {
if (this.assets.copyResult(id, next.id)) { stored.resultId = next.id; stored.completeness = previous.completeness }
this.assets.update(this.toPublic(stored), JSON.stringify(stored))
this.saveDraft(conversationId, draft.map((attachment) => attachment.id === id ? next.id : attachment.id))
this.remove(id)
return this.getDraft(conversationId)
} catch (error) { this.remove(next.id); throw error }
}
}
@@ -0,0 +1,87 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
describe('conversation attachment files and references', () => {
it('retains interrupted originals without turning them into sendable draft attachments', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
let storage = new ConversationAttachmentStorage(root, results)
try {
const conversation = randomUUID()
const bytes = Buffer.from('synthetic pending source')
const id = storage.beginParsing(conversation, 'pending.pdf', bytes)
storage.close()
results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
storage = new ConversationAttachmentStorage(root, results)
storage.reconcile((owner) => owner === conversation, true)
expect(storage.pendingParsing(conversation)).toMatchObject([{ id, parsingState: 'interrupted' }])
expect(storage.draft(conversation)).toEqual([])
expect(storage.original(id).data).toEqual(bytes)
storage.release('parsing', id)
storage.collect()
expect(storage.has(id)).toBe(false)
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
it('reopens originals and parsed documents after restart and retains shared references on deletion', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
let storage = new ConversationAttachmentStorage(root, results)
try {
const source = Buffer.from('original bytes')
const id = await storage.saveDocument('SOURCE.PDF', source, {
title: 'Source', sourceFormat: '.pdf', content: 'Parsed content', sections: [{ locator: 'Page 1', content: 'Parsed content' }],
warnings: [], parsingSettings: defaultDocumentParsingSettings
})
const context = { id, name: 'SOURCE.PDF', originalName: 'SOURCE.PDF', resourceId: id, resultId: id, kind: 'text' as const, size: 14, preview: 'Parsed content' }
storage.adoptDocument(context, JSON.stringify({ ...context, content: 'Parsed content' }))
const first = randomUUID(), second = randomUUID()
storage.reference(first, 'draft', first, [id])
storage.reference(second, 'message', randomUUID(), [id])
storage.close()
await results.close()
results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
storage = new ConversationAttachmentStorage(root, results)
expect(storage.draft(first)).toEqual([context])
expect(storage.original(id).data.equals(source)).toBe(true)
expect((await results.get(id)).content).toBe('Parsed content')
storage.deleteConversation(first)
expect(storage.original(id).data.equals(source)).toBe(true)
const originalPath = storage.original(id).path
storage.deleteConversation(second)
expect(storage.has(id)).toBe(false)
await expect(readFile(originalPath)).rejects.toThrow()
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
it('keeps image bytes in files and preserves queue resources when a draft is cleared', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const storage = new ConversationAttachmentStorage(root, results)
try {
const id = randomUUID(), conversation = randomUUID(), queue = randomUUID()
const image = Buffer.from('synthetic original image')
const context = { id, name: 'image.png', kind: 'image' as const, size: 4, preview: '4x4', thumbnailUrl: 'data:image/png;base64,AAAA', contentUrl: 'data:image/png;base64,BBBB', resourceId: id }
storage.save(context, JSON.stringify(context), image)
storage.reference(conversation, 'draft', conversation, [id])
storage.reference(conversation, 'queue', queue, [id])
storage.release('draft', conversation)
storage.collect()
expect(storage.original(id).data.equals(image)).toBe(true)
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
try {
const row = database.prepare('SELECT metadata FROM attachments WHERE id = ?').get(id) as { metadata: string }
expect(row.metadata).not.toContain('base64')
} finally { database.close() }
storage.release('queue', queue)
storage.collect()
expect(storage.has(id)).toBe(false)
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
})
+223
View File
@@ -0,0 +1,223 @@
import { DatabaseSync } from 'node:sqlite'
import { randomUUID } from 'node:crypto'
import { cpSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { extname, join, relative } from 'node:path'
import type { ContextAttachment } from '../shared/contracts'
import type { ParsedDocument } from './knowledge/document-parser'
import type { DocumentResultStorage } from './document-result-storage'
type AssetRow = { id: string; path: string; name: string; result_id: string | null; metadata: string }
function metadata(context: ContextAttachment): string {
const { thumbnailUrl: _thumbnail, contentUrl: _content, ...descriptor } = context
void _thumbnail
void _content
return JSON.stringify(descriptor)
}
function originalFileName(context: Pick<ContextAttachment, 'name' | 'originalMime'>): string {
return `original${context.originalMime ? `.${context.originalMime === 'image/jpeg' ? 'jpg' : context.originalMime.slice(6)}` : extname(context.name).toLowerCase()}`
}
export class ConversationAttachmentStorage {
private readonly database: DatabaseSync
constructor(private readonly root: string, private readonly results: DocumentResultStorage) {
mkdirSync(root, { recursive: true })
this.database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
const version = this.database.prepare('PRAGMA user_version').get() as { user_version: number }
if (version.user_version > 1) { this.database.close(); throw new Error('附件数据库版本较新,请升级应用') }
this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 5000;`)
if (version.user_version === 0) this.database.exec(`BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, path TEXT NOT NULL, name TEXT NOT NULL,
result_id TEXT UNIQUE, metadata TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS attachment_refs (
attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
conversation_id TEXT NOT NULL, kind TEXT NOT NULL, owner_id TEXT NOT NULL,
PRIMARY KEY (attachment_id, kind, owner_id)
);
CREATE INDEX IF NOT EXISTS attachment_refs_owner ON attachment_refs(kind, owner_id);
CREATE INDEX IF NOT EXISTS attachment_refs_conversation ON attachment_refs(conversation_id);
PRAGMA user_version = 1; COMMIT;`)
results.setPersistentLookup((id) => {
const row = this.database.prepare('SELECT * FROM attachments WHERE result_id = ?').get(id) as AssetRow | undefined
return row ? { directory: join(root, row.path), original: join(root, row.path, originalFileName({ ...JSON.parse(row.metadata), name: row.name })) } : undefined
})
}
private row(id: string): AssetRow {
const row = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
if (!row) throw new Error('附件资源不存在,请重新添加')
return row
}
has(id: string): boolean { return Boolean(this.database.prepare('SELECT 1 FROM attachments WHERE id = ?').get(id)) }
save(context: ContextAttachment, serialized: string, original: Buffer): void {
const path = join('temp', 'document-parsing', context.id)
const directory = join(this.root, path)
try {
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, originalFileName({ ...context, name: context.originalName ?? context.name })), original)
writeFileSync(join(directory, 'request.json'), serialized)
this.database.prepare('INSERT INTO attachments VALUES (?, ?, ?, ?, ?)').run(
context.id, path, context.originalName ?? context.name, context.resultId ?? null, metadata(context)
)
} catch (error) { rmSync(directory, { recursive: true, force: true }); throw error }
}
async saveDocument(name: string, original: Buffer, parsed: ParsedDocument): Promise<string> {
if (!parsed.parsingSettings) throw new Error('解析结果缺少生效配置')
const result = await this.results.save(name, original, parsed, parsed.parsingSettings, parsed.parsingDurationMs ?? 0)
return result.id
}
discardResult(id: string): Promise<void> { return this.results.release(id) }
adoptDocument(context: ContextAttachment, serialized: string): void {
const location = this.results.detach(context.resultId!)
try {
writeFileSync(join(location.directory, 'request.json'), serialized)
this.database.prepare('INSERT INTO attachments VALUES (?, ?, ?, ?, ?)').run(
context.id, relative(this.root, location.directory), context.originalName ?? context.name,
context.resultId!, metadata(context)
)
} catch (error) { rmSync(location.directory, { recursive: true, force: true }); throw error }
}
get(id: string): ContextAttachment {
const descriptor = JSON.parse(this.row(id).metadata) as ContextAttachment
const request = JSON.parse(this.request(id)) as ContextAttachment
return { ...descriptor, ...(request.thumbnailUrl ? { thumbnailUrl: request.thumbnailUrl } : {}), ...(request.kind === 'image' && 'data' in request && typeof request.data === 'string' && request.data.length <= 399_970 ? { contentUrl: `data:image/jpeg;base64,${request.data}` } : {}) }
}
update(context: ContextAttachment, serialized: string): void {
const row = this.row(context.id)
writeFileSync(join(this.root, row.path, 'request.json'), serialized)
this.database.prepare('UPDATE attachments SET metadata = ? WHERE id = ?').run(metadata(context), context.id)
}
copyResult(sourceId: string, targetId: string): boolean {
const source = this.row(sourceId)
if (!source.result_id) return false
const target = this.row(targetId)
const from = join(this.root, source.path), to = join(this.root, target.path)
const manifest = JSON.parse(readFileSync(join(from, 'manifest.json'), 'utf8'))
writeFileSync(join(to, 'parsed.md'), readFileSync(join(from, 'parsed.md')))
cpSync(join(from, 'images'), join(to, 'images'), { recursive: true })
writeFileSync(join(to, 'manifest.json'), JSON.stringify({ ...manifest, id: targetId }))
this.database.prepare('UPDATE attachments SET result_id = ? WHERE id = ?').run(targetId, targetId)
return true
}
request(id: string): string { return readFileSync(join(this.root, this.row(id).path, 'request.json'), 'utf8') }
original(id: string): { name: string; data: Buffer; path: string } {
const row = this.row(id)
const path = join(this.root, row.path, originalFileName({ ...JSON.parse(row.metadata), name: row.name }))
return { name: row.name, data: readFileSync(path), path }
}
reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void {
const assets = ids.filter((id) => this.has(id)).map((id) => this.row(id))
for (const asset of assets) {
if (!asset.path.startsWith(`temp`)) continue
const context = JSON.parse(asset.metadata) as ContextAttachment
const target = join('conversation-assets', conversationId, context.kind === 'image' ? 'images' : 'documents', asset.id)
mkdirSync(join(this.root, 'conversation-assets', conversationId, context.kind === 'image' ? 'images' : 'documents'), { recursive: true })
renameSync(join(this.root, asset.path), join(this.root, target))
try {
this.database.prepare('UPDATE attachments SET path = ? WHERE id = ?').run(target, asset.id)
} catch (error) {
renameSync(join(this.root, target), join(this.root, asset.path))
throw error
}
}
this.database.exec('BEGIN IMMEDIATE')
try {
this.database.prepare('DELETE FROM attachment_refs WHERE kind = ? AND owner_id = ?').run(kind, ownerId)
const insert = this.database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)')
for (const asset of assets) insert.run(asset.id, conversationId, kind, ownerId)
this.database.exec('COMMIT')
} catch (error) { this.database.exec('ROLLBACK'); throw error }
}
draft(conversationId: string): ContextAttachment[] {
return (this.database.prepare(`SELECT a.id FROM attachments a JOIN attachment_refs r ON r.attachment_id = a.id
WHERE r.kind = 'draft' AND r.owner_id = ? ORDER BY r.rowid`).all(conversationId) as { id: string }[])
.map((row) => this.get(row.id))
}
release(kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string): void {
this.database.prepare('DELETE FROM attachment_refs WHERE kind = ? AND owner_id = ?').run(kind, ownerId)
}
deleteConversation(conversationId: string, protectedIds: readonly string[] = []): void {
this.database.prepare('DELETE FROM attachment_refs WHERE conversation_id = ?').run(conversationId)
this.collect(protectedIds)
}
private collectOrphanDirectories(): void {
const owned = new Set((this.database.prepare('SELECT path FROM attachments').all() as { path: string }[]).map((row) => row.path))
const directories = (path: string): string[] => {
try { return readdirSync(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 }
}
const candidates = directories(join(this.root, 'temp', 'document-parsing')).map((id) => join('temp', 'document-parsing', id))
for (const conversationId of directories(join(this.root, 'conversation-assets'))) {
for (const kind of ['images', 'documents']) {
for (const id of directories(join(this.root, 'conversation-assets', conversationId, kind))) candidates.push(join('conversation-assets', conversationId, kind, id))
}
}
for (const path of candidates) {
if (owned.has(path) || this.results.isTemporaryResult(path.split(/[\\/]/u).at(-1)!)) continue
rmSync(join(this.root, path), { recursive: true, force: true })
}
}
collect(protectedIds: readonly string[] = []): void {
const protectedSet = new Set(protectedIds)
const rows = this.database.prepare(`SELECT a.* FROM attachments a WHERE NOT EXISTS
(SELECT 1 FROM attachment_refs r WHERE r.attachment_id = a.id)`).all() as AssetRow[]
for (const row of rows) {
if (protectedSet.has(row.id)) continue
try {
rmSync(join(this.root, row.path), { recursive: true, force: true })
this.database.prepare('DELETE FROM attachments WHERE id = ?').run(row.id)
} catch {
// The unreferenced row remains authoritative for the next cleanup attempt.
console.warn('Attachment cleanup deferred; unreferenced files will be retried on the next cleanup')
}
}
}
reconcile(hasOwner: (conversationId: string, kind: string, ownerId: string) => boolean, startup = false, protectedIds: readonly string[] = []): void {
const owners = this.database.prepare('SELECT DISTINCT conversation_id, kind, owner_id FROM attachment_refs').all() as { conversation_id: string; kind: string; owner_id: string }[]
for (const owner of owners) {
if (!hasOwner(owner.conversation_id, owner.kind, owner.owner_id)) {
this.database.prepare('DELETE FROM attachment_refs WHERE conversation_id = ? AND kind = ? AND owner_id = ?').run(owner.conversation_id, owner.kind, owner.owner_id)
}
}
if (startup) this.database.exec("UPDATE attachments SET metadata = json_set(metadata, '$.parsingState', 'interrupted') WHERE json_extract(metadata, '$.parsingState') = 'parsing'")
this.collect(protectedIds)
if (startup) this.collectOrphanDirectories()
}
beginParsing(conversationId: string, name: string, original: Buffer): string {
const id = randomUUID()
const context: ContextAttachment = { id, resourceId: id, name, originalName: name, originalSize: original.length, kind: 'text', size: 0, preview: '', parsingState: 'parsing' }
this.save(context, JSON.stringify(context), original)
this.reference(conversationId, 'parsing', id, [id])
return id
}
parsingFailed(id: string, interrupted: boolean, error: string): void {
const context = this.get(id)
this.update({ ...context, parsingState: interrupted ? 'interrupted' : 'failed', parsingError: error.slice(0, 1000) }, this.request(id))
}
pendingParsing(conversationId: string): ContextAttachment[] {
return (this.database.prepare(`SELECT a.id FROM attachments a JOIN attachment_refs r ON r.attachment_id = a.id
WHERE r.kind = 'parsing' AND r.conversation_id = ? AND json_extract(a.metadata, '$.parsingState') IN ('failed', 'interrupted')`).all(conversationId) as { id: string }[]).map((row) => this.get(row.id))
}
close(): void { this.database.close() }
}
@@ -87,6 +87,7 @@ function createService(overrides?: {
}))
)
const settingsStore = {
forOperation: vi.fn(async () => ({ settings, apiKey: () => undefined })),
get: vi.fn(async () => settings),
update: vi.fn(async () => settings)
}
+164 -13
View File
@@ -1,8 +1,10 @@
import { extname } from 'node:path'
import { convert } from 'html-to-text'
import {
documentParsingDiagnosticSchema,
documentParsingSettingsUpdateSchema,
documentParsingSnapshotSchema,
defaultHttpOcrSettings,
maximumDocumentExtractedCharacters,
maximumDocumentParsingWarnings,
type DocumentParsingDiagnostic,
@@ -13,6 +15,9 @@ import {
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 { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
import type { DocumentResultStorage } from './document-result-storage'
import { extractPptxPages } from './knowledge/pptx-parser'
import {
assertDocumentBuffer,
@@ -153,10 +158,12 @@ function nativePdfSections(pages: PdfTextPage[]): ParsedSection[] {
}
export class DocumentParsingService {
private readonly diagnosticOperations = new Map<string, AbortController>()
constructor(
private readonly settingsStore: DocumentParsingSettingsStore,
private readonly modelManager: DocumentOcrModelManager,
private readonly ocrBroker: DocumentOcrBroker
private readonly ocrBroker: DocumentOcrBroker,
readonly results?: DocumentResultStorage
) {}
async snapshot(): Promise<DocumentParsingSnapshot> {
@@ -173,6 +180,7 @@ export class DocumentParsingService {
localOcr
},
ocrModels,
httpCredentialConfigured: await this.settingsStore.credentialConfigured(),
...(this.settingsStore.getWarnings().length > 0
? { warnings: [...this.settingsStore.getWarnings()] }
: {})
@@ -183,7 +191,12 @@ export class DocumentParsingService {
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
) {
@@ -198,22 +211,77 @@ export class DocumentParsingService {
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 (!convert(content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()) 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, signal)
return this.parsePptx(name, buffer, purpose, operation, signal)
}
if (extname(name).toLowerCase() !== '.pdf') {
return parseDocument(name, buffer, signal)
}
const settings = await this.settingsStore.get()
const extracted = await extractPdfTextPages(buffer, { signal })
const { pages } = extracted
ensureNotAborted(signal)
@@ -266,6 +334,58 @@ export class DocumentParsingService {
)
}
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
)
@@ -340,7 +460,7 @@ export class DocumentParsingService {
)
if (
missingOcrPage !== undefined &&
purpose === 'knowledge-index'
(purpose === 'knowledge-index' || mode === 'always')
) {
throw new Error(`第 ${missingOcrPage} 页未识别到可索引文本`)
}
@@ -378,6 +498,7 @@ export class DocumentParsingService {
extracted.pageCount,
[
...ocr.warnings,
...(missingOcrPage === undefined ? [] : [`第 ${missingOcrPage} 页未完成 OCR,当前为部分解析结果`]),
...(extracted.truncated
? ['文档提取文本超过 5,000,000 字符,已截断']
: [])
@@ -389,9 +510,10 @@ export class DocumentParsingService {
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose,
operation: Awaited<ReturnType<DocumentParsingSettingsStore['forOperation']>>,
signal?: AbortSignal
): Promise<ParsedDocument> {
const settings = await this.settingsStore.get()
const { settings } = operation
const mode = effectiveOcrMode(settings, purpose)
if (mode === 'disabled') {
try {
@@ -423,15 +545,18 @@ export class DocumentParsingService {
)
}
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
)
if (imagePages.length > 0) {
try {
const status = await this.modelManager.getStatus(settings.localOcrModelId)
if (!status.available || !status.verified) {
throw new Error(status.detail)
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()) {
@@ -448,9 +573,12 @@ export class DocumentParsingService {
maximumPages: 1,
pageTimeoutSeconds: settings.pageTimeoutSeconds
}
const result = await (signal
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,
@@ -491,23 +619,34 @@ export class DocumentParsingService {
}
}
sections.sort((left, right) => (left.pageNumber ?? 0) - (right.pageNumber ?? 0))
return buildPagedDocument(name, sections, pages.length, warnings, '.pptx')
return { ...buildPagedDocument(name, sections, pages.length, warnings, '.pptx'), ...(images.length ? { images } : {}), ...(missingImages.length ? { missingImages } : {}) }
}
async diagnose(
name: string,
buffer: Buffer,
purpose: DocumentParsingPurpose = 'diagnostic'
purpose: DocumentParsingPurpose = 'diagnostic',
operationId?: string
): Promise<DocumentParsingDiagnostic> {
const startedAt = Date.now()
const parsed = await this.parse(name, buffer, purpose)
const controller = new AbortController()
if (operationId) {
if (this.diagnosticOperations.has(operationId)) throw new Error('解析任务已在运行')
this.diagnosticOperations.set(operationId, controller)
}
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'
).map((section) => section.pageNumber ?? section.locator)).size
).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',
@@ -527,5 +666,17 @@ export class DocumentParsingService {
preview: parsed.content.slice(0, 2_000),
warnings: parsed.warnings
})
} finally {
if (operationId) this.diagnosticOperations.delete(operationId)
}
}
cancelDiagnostic(operationId: string): void {
this.diagnosticOperations.get(operationId)?.abort(new Error('文档解析已取消'))
}
async dispose(): Promise<void> {
for (const controller of this.diagnosticOperations.values()) controller.abort(new Error('应用正在退出'))
await this.results?.close()
}
}
@@ -12,6 +12,7 @@ import {
defaultDocumentParsingSettings,
DocumentParsingSettingsStore
} from './document-parsing-settings-store'
import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts'
const temporaryDirectories: string[] = []
@@ -41,6 +42,23 @@ afterEach(async () => {
})
describe('DocumentParsingSettingsStore', () => {
it('freezes credentials with the task settings and keeps hidden provider configuration', async () => {
const { filePath } = await createStore()
const cipher = { isAvailable: () => true, encrypt: (value: string) => Buffer.from(value).reverse(), decrypt: (value: Buffer) => Buffer.from(value).reverse().toString('utf8') }
const store = new DocumentParsingSettingsStore(filePath, cipher)
await store.update({ ...defaultDocumentParsingSettings, ocrProvider: 'paddleocr-vl', httpOcr: { ...defaultHttpOcrSettings, baseUrl: 'http://first.test', authentication: 'bearer' }, apiKey: 'fixture-key-a' })
const operation = await store.forOperation()
await store.update({ ...(await store.get()), ocrProvider: 'local', httpOcr: { ...defaultHttpOcrSettings, baseUrl: 'http://second.test', authentication: 'bearer' }, apiKey: 'fixture-key-b' })
expect(operation.settings.httpOcr?.baseUrl).toBe('http://first.test')
expect(operation.apiKey()).toBe('fixture-key-a')
expect(await store.getApiKey()).toBe('fixture-key-b')
expect(JSON.stringify(await store.get())).not.toContain('fixture-key')
expect(await readFile(filePath, 'utf8')).not.toContain('fixture-key')
await store.update({ ...(await store.get()), apiKey: '' })
expect(await store.getApiKey()).toBe('fixture-key-b')
await store.update({ ...(await store.get()), clearApiKey: true })
expect(await store.getApiKey()).toBeUndefined()
})
it('returns local-first defaults without creating a file', async () => {
const { directory, store } = await createStore()
@@ -61,7 +79,7 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.update(settings)).resolves.toEqual(settings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 3,
version: 4,
...settings
})
await expect(
+50 -6
View File
@@ -6,6 +6,10 @@ import {
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,
@@ -14,12 +18,13 @@ import {
writeJsonFileAtomically
} from './settings-file-utils'
const CURRENT_SETTINGS_VERSION = 3
const CURRENT_SETTINGS_VERSION = 4
const storedDocumentParsingSettingsSchema =
documentParsingSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION)
version: z.literal(CURRENT_SETTINGS_VERSION),
credential: encryptedSettingsCredentialSchema.optional()
})
.strict()
@@ -102,7 +107,10 @@ export class DocumentParsingSettingsStore {
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
constructor(
private readonly filePath: string,
private readonly cipher?: SettingsCredentialCipher
) {}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
@@ -147,6 +155,11 @@ export class DocumentParsingSettingsStore {
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) {
@@ -195,8 +208,9 @@ export class DocumentParsingSettingsStore {
}
async get(): Promise<DocumentParsingSettings> {
const { version: _version, ...settings } = await this.loadStored()
const { version: _version, credential: _credential, ...settings } = await this.loadStored()
void _version
void _credential
return documentParsingSettingsSchema.parse(settings)
}
@@ -204,12 +218,42 @@ export class DocumentParsingSettingsStore {
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 updates = documentParsingSettingsUpdateSchema.parse(input)
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
...updates,
...(credential ? { credential } : {})
}
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
+123
View File
@@ -0,0 +1,123 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
import { convert } from 'html-to-text'
import { createCanvas, loadImage } from '@napi-rs/canvas'
import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts'
import type { DocumentParsingSettings } from '../shared/document-parsing-contracts'
import type { ParsedDocument } from './knowledge/document-parser'
export function parsedCompleteness(parsed: ParsedDocument): DocumentResult['completeness'] {
const text = convert(parsed.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()
return !text && parsed.images?.length ? 'images-only' : parsed.warnings.length ? 'partial' : 'complete'
}
export function originalImageMime(data: Buffer): 'image/png' | 'image/jpeg' | 'image/webp' | undefined {
if (data.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) return 'image/jpeg'
if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'
if (data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
return undefined
}
export class DocumentResultStorage {
private readonly results = new Map<string, { directory: string; original: string }>()
private readonly persistentLookups: Array<(id: string) => { directory: string; original: string } | undefined> = []
constructor(private readonly root: string) {}
setPersistentLookup(lookup: (id: string) => { directory: string; original: string } | undefined): void {
this.persistentLookups.unshift(lookup)
}
detach(id: string): { directory: string; original: string } {
const location = this.require(id)
this.results.delete(id)
return location
}
isTemporaryResult(id: string): boolean { return this.results.has(id) }
async move(id: string, directory: string): Promise<void> {
const current = this.require(id)
await mkdir(dirname(directory), { recursive: true })
await rename(current.directory, directory)
this.results.set(id, { directory, original: dirname(current.original) === current.directory ? join(directory, basename(current.original)) : current.original })
}
async save(name: string, original: Buffer | undefined, parsed: ParsedDocument, settings: DocumentParsingSettings, durationMs: number, originalPathOverride?: string, signal?: AbortSignal): Promise<DocumentResult> {
signal?.throwIfAborted()
const id = randomUUID()
const directory = join(this.root, id)
const mime = original ? originalImageMime(original) : undefined
const originalPath = originalPathOverride ?? join(directory, `original${mime ? `.${mime === 'image/jpeg' ? 'jpg' : mime.slice(6)}` : extname(name).toLowerCase()}`)
this.results.set(id, { directory, original: originalPath })
try {
await mkdir(join(directory, 'images'), { recursive: true })
if (original) await writeFile(originalPath, original)
const images = await Promise.all((parsed.images ?? []).map(async ({ data, ...image }) => {
await writeFile(join(directory, 'images', image.id), data)
return { ...image, size: data.byteLength }
}))
const result = documentResultSchema.parse({
id, fileName: name, sourceFormat: parsed.sourceFormat, content: parsed.content,
sections: parsed.sections, images, pageCount: parsed.pageCount,
missingImages: parsed.missingImages ?? [],
warnings: parsed.warnings,
completeness: parsedCompleteness(parsed),
parsedAt: new Date().toISOString(), durationMs, settings, restructure: parsed.restructure
})
await writeFile(join(directory, 'parsed.md'), parsed.content)
await writeFile(join(directory, 'manifest.json'), JSON.stringify(result))
signal?.throwIfAborted()
return result
} catch (error) {
await rm(directory, { recursive: true, force: true })
this.results.delete(id)
throw error
}
}
private require(id: string): { directory: string; original: string } {
const temporary = this.results.get(id)
if (temporary) return temporary
for (const lookup of this.persistentLookups) {
const result = lookup(id)
if (result) return result
}
throw new Error('解析结果已关闭或不可用')
}
async get(id: string): Promise<DocumentResult> {
return documentResultSchema.parse(JSON.parse(await readFile(join(this.require(id).directory, 'manifest.json'), 'utf8')))
}
async image(id: string, imageId: string, thumbnail = false): Promise<string> {
const result = await this.get(id)
const image = result.images.find((candidate) => candidate.id === imageId)
if (!image) throw new Error('解析图片不存在')
const data = await readFile(join(this.require(id).directory, 'images', imageId))
if (thumbnail) {
const source = await loadImage(data)
const scale = Math.min(1, 640 / Math.max(source.width, source.height))
const canvas = createCanvas(Math.max(1, Math.round(source.width * scale)), Math.max(1, Math.round(source.height * scale)))
const context = canvas.getContext('2d')
context.fillStyle = '#ffffff'
context.fillRect(0, 0, canvas.width, canvas.height)
context.drawImage(source, 0, 0, canvas.width, canvas.height)
return `data:image/jpeg;base64,${(await canvas.encode('jpeg', 80)).toString('base64')}`
}
return `data:${image.mimeType};base64,${data.toString('base64')}`
}
original(id: string): string { return this.require(id).original }
async release(id: string): Promise<void> {
const result = this.results.get(id)
if (!result) return
await rm(result.directory, { recursive: true, force: true })
this.results.delete(id)
}
async close(): Promise<void> {
await Promise.all([...this.results.keys()].map((id) => this.release(id)))
}
}
@@ -0,0 +1,41 @@
// @vitest-environment node
import { createServer } from 'node:http'
import { once } from 'node:events'
import type { AddressInfo } from 'node:net'
import { expect, it } from 'vitest'
import { HttpDocumentOcr } from './http-document-ocr'
import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts'
it('uses the production HTTP client for Bearer authentication, errors, cancellation and limits', async () => {
const calls: Array<{ authorization?: string; path?: string }> = []
const server = createServer(async (request, response) => {
calls.push({ authorization: request.headers.authorization, path: request.url })
for await (const _chunk of request) void _chunk
if (request.headers.authorization !== 'Bearer synthetic-test-key') { response.writeHead(401); response.end('fixture'); return }
if (request.url === '/health') { response.end('{}'); return }
if (request.url === '/openapi.json') { response.end(JSON.stringify({ components: { schemas: { InferRequest: { properties: { useChartRecognition: {} } } } } })); return }
if (request.url?.startsWith('/wait/')) { request.socket.once('close', () => response.destroy()); return }
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({ result: { layoutParsingResults: [{ prunedResult: {}, markdown: { text: 'Authenticated synthetic text', images: {} } }] } }))
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
try {
const settings = { ...defaultHttpOcrSettings, baseUrl, authentication: 'bearer' as const }
const client = new HttpDocumentOcr(settings, 'synthetic-test-key')
expect((await client.check()).declaredOptions).toEqual(['useChartRecognition'])
expect((await client.recognize(Buffer.from('fixture'), 1, [8])).sections[0]).toMatchObject({ pageNumber: 8, content: 'Authenticated synthetic text' })
await expect(new HttpDocumentOcr(settings, 'wrong-key').recognize(Buffer.from('fixture'), 1, [1])).rejects.toThrow('HTTP 401')
const controller = new AbortController()
const pending = new HttpDocumentOcr({ ...settings, baseUrl: `${baseUrl}/wait` }, 'synthetic-test-key').recognize(Buffer.from('fixture'), 1, [1], controller.signal)
const cancelled = expect(pending).rejects.toThrow('cancel fixture')
while (calls.length < 5) await new Promise(resolve => setTimeout(resolve, 5))
controller.abort(new Error('cancel fixture'))
await cancelled
expect(calls.filter(call => call.authorization === 'Bearer synthetic-test-key')).toHaveLength(4)
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
+89
View File
@@ -0,0 +1,89 @@
import { createCanvas } from '@napi-rs/canvas'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts'
import { HttpDocumentOcr } from './http-document-ocr'
afterEach(() => vi.unstubAllGlobals())
function page(text: string, images: Record<string, string> = {}) {
return { prunedResult: {}, markdown: { text, images } }
}
describe('HTTP document OCR', () => {
it('uses changed restructuring output with all source pages and retains page-local images', async () => {
const bytes = createCanvas(4, 4).toBuffer('image/png').toString('base64')
const fetch = vi.fn().mockResolvedValueOnce(Response.json({ result: { layoutParsingResults: [
page('first ![figure](a)', { a: bytes }), page('second')
] } })).mockResolvedValueOnce(Response.json({ result: { layoutParsingResults: [page('merged ![figure](a)', { a: bytes })] } }))
vi.stubGlobal('fetch', fetch)
const result = await new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', mergeTables: true })
.recognize(Buffer.from('pdf'), 0, [5, 6])
expect(result.sections).toHaveLength(1)
expect(result.sections[0]).toMatchObject({ sourcePages: [5, 6], content: expect.stringContaining('merged') })
expect(result.sections[0]).not.toHaveProperty('pageNumber')
expect(result.images?.[0]?.pageNumber).toBe(5)
expect(result.restructure?.changed).toBe(true)
})
it('retains page results and reports a restructuring HTTP failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(Response.json({ result: { layoutParsingResults: [page('first'), page('second')] } }))
.mockResolvedValueOnce(new Response('', { status: 503 })))
const result = await new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', mergeTables: true })
.recognize(Buffer.from('pdf'), 0, [1, 2])
expect(result.sections.map(section => section.content)).toEqual(['first', 'second'])
expect(result.warnings[0]).toContain('HTTP 503')
})
it('does not start restructuring when cancellation arrives after layout', async () => {
const controller = new AbortController()
const fetch = vi.fn(async () => { controller.abort(new Error('cancelled')); return Response.json({ result: { layoutParsingResults: [page('first')] } }) })
vi.stubGlobal('fetch', fetch)
await expect(new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', mergeTables: true })
.recognize(Buffer.from('pdf'), 0, [1], controller.signal)).rejects.toThrow('cancelled')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('preserves page-local image identity, rewrites HTML and Markdown, and omits confidence', async () => {
const bytes = createCanvas(4, 4).toBuffer('image/png').toString('base64')
const fetch = vi.fn(async () => Response.json({ result: { layoutParsingResults: [
page('<img src="imgs/a.png">', { 'imgs/a.png': bytes }),
page('![figure](imgs/a.png)', { 'imgs/a.png': bytes })
] } }))
vi.stubGlobal('fetch', fetch)
const provider = new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', filterMode: 'all' })
const result = await provider.recognize(Buffer.from('pdf'), 0, [3, 4])
expect(result.images).toHaveLength(2)
expect(result.images![0]!.id).not.toBe(result.images![1]!.id)
expect(result.images!.map((image) => image.pageNumber)).toEqual([3, 4])
expect(result.sections[0]!.content).toContain(`asset:${result.images![0]!.id}`)
expect(result.sections[1]!.content).toContain(`asset:${result.images![1]!.id}`)
expect(result.sections[0]).not.toHaveProperty('confidence')
const request = (fetch.mock.calls as unknown as [string, RequestInit][])[0]![1]
expect(JSON.parse(request.body as string)).toMatchObject({ markdownIgnoreLabels: [], returnMarkdownImages: true, visualize: false })
})
it('does not fetch URL images and replaces broken references with an explicit warning', async () => {
const fetch = vi.fn(async () => Response.json({ result: { layoutParsingResults: [page('<img src="figure"> text', { figure: 'http://never.test/private' })] } }))
vi.stubGlobal('fetch', fetch)
const result = await new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test' })
.recognize(Buffer.from('image'), 1, [1])
expect(fetch).toHaveBeenCalledTimes(1)
expect(result.images).toEqual([])
expect(result.warnings[0]).toContain('未返回内联图片')
expect(result.sections[0]!.content).not.toContain('<img')
})
it.each([401, 429, 503])('preserves HTTP %s without returning provider secrets', async (status) => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('secret response', { status })))
await expect(new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test' })
.recognize(Buffer.from('image'), 1, [1])).rejects.toThrow(`HTTP ${status}`)
})
it('does not dispatch after cancellation or with missing credentials', async () => {
const fetch = vi.fn()
vi.stubGlobal('fetch', fetch)
const provider = new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', authentication: 'bearer' })
await expect(provider.recognize(Buffer.from('image'), 1, [1])).rejects.toThrow('凭据未配置')
await expect(provider.recognize(Buffer.from('image'), 1, [1], AbortSignal.abort())).rejects.toThrow()
expect(fetch).not.toHaveBeenCalled()
})
})
+210
View File
@@ -0,0 +1,210 @@
import { randomUUID } from 'node:crypto'
import { loadImage } from '@napi-rs/canvas'
import { convert } from 'html-to-text'
import { z } from 'zod'
import {
maximumDocumentExtractedCharacters,
type httpOcrSettingsSchema
} from '../shared/document-parsing-contracts'
import type { ParsedDocument, ParsedSection } from './knowledge/document-parser'
type Settings = z.infer<typeof httpOcrSettingsSchema>
export type ParsedDocumentImage = {
id: string
pageNumber: number
key: string
locator?: string
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
width: number
height: number
data: Buffer
}
const maximumResponseBytes = 32 * 1024 * 1024
const pageSchema = z.object({
prunedResult: z.record(z.string(), z.unknown()),
markdown: z.object({
text: z.string().max(maximumDocumentExtractedCharacters),
images: z.record(z.string(), z.string()).nullish()
})
})
const responseSchema = z.object({
errorCode: z.number().optional(),
result: z.object({ layoutParsingResults: z.array(pageSchema).min(1).max(500) })
})
export class HttpDocumentOcr {
constructor(private readonly settings: Settings, private readonly apiKey?: string) {}
private async request(path: string, body?: unknown, signal?: AbortSignal): Promise<unknown> {
signal?.throwIfAborted()
if (!this.settings.baseUrl) throw new Error('请在文档解析设置中保存 HTTP OCR 服务地址')
if (this.settings.authentication === 'bearer' && !this.apiKey) {
throw new Error('HTTP OCR Bearer 凭据未配置')
}
const timeout = AbortSignal.timeout(this.settings.timeoutSeconds * 1000)
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
const response = await fetch(`${this.settings.baseUrl.replace(/\/+$/u, '')}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
...(this.settings.authentication === 'bearer' ? { Authorization: `Bearer ${this.apiKey}` } : {})
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: combined,
redirect: 'error'
})
if (!response.ok) {
await response.body?.cancel()
throw new Error(`HTTP OCR ${path}:HTTP ${response.status}`)
}
if (!response.body) throw new Error('HTTP OCR 返回空响应')
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.byteLength
if (size > maximumResponseBytes) throw new Error('HTTP OCR 响应超过 32 MiB 限制')
chunks.push(chunk.value)
}
} finally {
await reader.cancel()
reader.releaseLock()
}
combined.throwIfAborted()
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const envelope = z.object({ errorCode: z.number().optional() }).passthrough().parse(value)
if (envelope.errorCode && envelope.errorCode !== 0) {
throw new Error(`HTTP OCR ${path}:服务错误 ${envelope.errorCode};请检查部署模型和所选高级选项`)
}
return value
}
async check(): Promise<{ checkedAt: string; declaredOptions: string[] }> {
await this.request('/health')
const schema = z.object({ components: z.object({ schemas: z.record(z.string(), z.object({ properties: z.record(z.string(), z.unknown()).optional() }).passthrough()) }).optional() })
.parse(await this.request('/openapi.json'))
return { checkedAt: new Date().toISOString(), declaredOptions: Object.keys(schema.components?.schemas.InferRequest?.properties ?? {}) }
}
async recognize(
data: Buffer,
fileType: 0 | 1,
sourcePages: number[],
signal?: AbortSignal
): Promise<Pick<ParsedDocument, 'sections' | 'images' | 'missingImages' | 'warnings' | 'restructure'>> {
const labels = this.settings.filterMode === 'default' ? undefined
: this.settings.filterMode === 'all' ? [] : this.settings.ignoredLabels
const response = responseSchema.parse(await this.request('/layout-parsing', {
file: data.toString('base64'), fileType,
...this.settings.options,
...(labels === undefined ? {} : { markdownIgnoreLabels: labels }),
returnMarkdownImages: true, visualize: false
}, signal))
const pages = response.result.layoutParsingResults
if (pages.length !== sourcePages.length) throw new Error('HTTP OCR 返回页数与输入不符,无法确定来源页码')
const warnings: string[] = []
const images: ParsedDocumentImage[] = []
const missingImages: NonNullable<ParsedDocument['missingImages']> = []
const sections: ParsedSection[] = []
let imageBytes = 0
let characters = 0
for (const [index, page] of pages.entries()) {
signal?.throwIfAborted()
const pageNumber = sourcePages[index]!
let content = page.markdown.text
const references = [...content.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)|<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/giu)]
for (const key of new Set(references.map((match) => match[1] ?? match[2]!))) {
signal?.throwIfAborted()
const encoded = page.markdown.images?.[key]
let replacement: string
try {
if (!encoded || /^https?:/iu.test(encoded)) throw new Error('未返回内联图片字节')
const raw = encoded.replace(/^data:image\/(?:jpeg|png|webp);base64,/iu, '')
if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(raw)) throw new Error('Base64 无效')
const bytes = Buffer.from(raw, 'base64')
if (!bytes.length || bytes.length > 12 * 1024 * 1024) throw new Error('图片超过 12 MiB 或为空')
const mimeType = bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ? 'image/jpeg'
: bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png'
: bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined
if (!mimeType) throw new Error('图片格式不支持')
const image = await loadImage(bytes)
if (!image.width || !image.height || image.width * image.height > 40_000_000) throw new Error('图片尺寸无效或超过 4000 万像素')
imageBytes += bytes.length
if (imageBytes > maximumResponseBytes) throw new Error('解码图片总量超过 32 MiB')
const id = randomUUID()
images.push({ id, pageNumber, key, mimeType, width: image.width, height: image.height, data: bytes })
replacement = `![第 ${pageNumber} 页图片](asset:${id})`
} catch (error) {
signal?.throwIfAborted()
missingImages.push({ pageNumber, key, reason: error instanceof Error ? error.message : '解码失败' })
const warning = `第 ${pageNumber} 页图片未保存:${error instanceof Error ? error.message : '解码失败'}`
warnings.push(warning)
replacement = `[${warning}]`
}
for (const reference of references.filter((match) => (match[1] ?? match[2]) === key)) {
content = content.replace(reference[0], replacement)
}
}
characters += content.length
if (characters > maximumDocumentExtractedCharacters) throw new Error('HTTP OCR 文字超过 5,000,000 字符')
sections.push({ locator: `第 ${pageNumber} 页`, pageNumber, sourcePages: [pageNumber], content, method: 'ocr' })
if (!content.trim()) warnings.push(`第 ${pageNumber} 页未返回文字或有效插图`)
}
// Restructuring is only meaningful for contiguous original pages.
let restructure: ParsedDocument['restructure']
if (this.settings.mergeTables || this.settings.relevelTitles) {
if (sourcePages.some((page, index) => index > 0 && page !== sourcePages[index - 1]! + 1)) {
throw new Error('不连续来源页不能进行跨页整理')
}
signal?.throwIfAborted()
try {
const result = responseSchema.parse(await this.request('/restructure-pages', {
pages: pages.map((page) => ({ prunedResult: page.prunedResult, markdownImages: page.markdown.images ?? {} })),
mergeTables: this.settings.mergeTables, relevelTitles: this.settings.relevelTitles,
...(this.settings.options.prettifyMarkdown === undefined ? {} : { prettifyMarkdown: this.settings.options.prettifyMarkdown }),
...(this.settings.options.showFormulaNumber === undefined ? {} : { showFormulaNumber: this.settings.options.showFormulaNumber }),
returnMarkdownImages: true,
concatenatePages: false
}, signal))
const changed = JSON.stringify(result.result.layoutParsingResults.map((page) => page.markdown.text)) !== JSON.stringify(pages.map((page) => page.markdown.text))
restructure = { changed, sourcePages }
if (changed) {
try {
const merged: string[] = []
for (const page of result.result.layoutParsingResults) {
let content = page.markdown.text
const references = [...content.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)|<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/giu)]
for (const reference of references) {
const key = reference[1] ?? reference[2]!
const encoded = page.markdown.images?.[key]
if (!encoded || /^https?:/iu.test(encoded)) throw new Error('重组图片缺少内联数据')
const bytes = Buffer.from(encoded.replace(/^data:image\/(?:jpeg|png|webp);base64,/iu, ''), 'base64')
const matches = images.filter((image) => image.data.equals(bytes))
if (matches.length !== 1) throw new Error('重组图片不能唯一对应原始页面')
content = content.replace(reference[0], `![第 ${matches[0]!.pageNumber} 页图片](asset:${matches[0]!.id})`)
}
merged.push(content)
}
const content = merged.join('\n\n')
if (!content.trim() || content.length > maximumDocumentExtractedCharacters) throw new Error('重组正文为空或超过容量限制')
// The grouped section represents all input pages, never a guessed single page.
sections.splice(0, sections.length, { locator: `跨页整理 · 来源页 ${sourcePages.join('、')}`, sourcePages, content, method: 'ocr' })
} catch (error) {
warnings.push(`跨页整理未完成,保留逐页结果:${error instanceof Error ? error.message : '来源映射失败'}`)
}
}
} catch (error) {
signal?.throwIfAborted()
warnings.push(`跨页整理请求失败,保留逐页结果:${error instanceof Error ? error.message : '请求失败'}`)
}
}
signal?.throwIfAborted()
const text = sections.map((section) => convert(section.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, '').replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, ''), { wordwrap: false })).join('').trim()
if (!text && images.length === 0) throw new Error('HTTP OCR 未返回文字或有效图片')
return { sections, images, missingImages, warnings, ...(restructure ? { restructure } : {}) }
}
}
+22 -6
View File
@@ -92,6 +92,8 @@ import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker'
import { DocumentParsingService } from './document-parsing-service'
import { DocumentResultStorage } from './document-result-storage'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { ReleaseNotesService } from './release-notes-service'
import { GoodBuddyConfigService } from './goodbuddy-config-service'
import {
@@ -216,6 +218,8 @@ let browserService: BrowserService | undefined
let globalTlsPolicy: GlobalTlsPolicy | undefined
let feedbackService: FeedbackService | undefined
let documentOcrBroker: DocumentOcrBroker | undefined
let documentParsingService: DocumentParsingService | undefined
let conversationAttachmentStorage: ConversationAttachmentStorage | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let embeddingModelManager: EmbeddingModelManager | undefined
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
@@ -780,7 +784,8 @@ if (hasSingleInstanceLock) {
})
const documentParsingSettingsStore =
new DocumentParsingSettingsStore(
join(app.getPath('userData'), 'document-parsing-settings.json')
join(app.getPath('userData'), 'document-parsing-settings.json'),
secureCipher
)
documentOcrModelManager = new DocumentOcrModelManager({
userDataDirectory: app.getPath('userData'),
@@ -789,14 +794,15 @@ if (hasSingleInstanceLock) {
(await applicationSettingsStore.get()).modelDownloadSource
})
documentOcrBroker = new DocumentOcrBroker(mainWindow)
const documentParsingService = new DocumentParsingService(
documentParsingService = new DocumentParsingService(
documentParsingSettingsStore,
documentOcrModelManager,
documentOcrBroker
documentOcrBroker,
new DocumentResultStorage(join(app.getPath('userData'), 'temp', 'document-parsing'))
)
localInferenceService.register('ocr', {
snapshot: async () => {
const snapshot = await documentParsingService.snapshot()
const snapshot = await documentParsingService!.snapshot()
const model = snapshot.status.localOcr
return {
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
@@ -886,6 +892,7 @@ if (hasSingleInstanceLock) {
runtimeExtensionStore.markStartupFailed(extensionIds)
})
const startupKnowledgeService = new KnowledgeService({
documentResults: documentParsingService.results,
credentialCipher: secureCipher,
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
managedRoot: join(app.getPath('userData'), 'knowledge'),
@@ -1202,8 +1209,14 @@ if (hasSingleInstanceLock) {
createSelectedStatusRuntime,
(conversationId) => localRuntimeRegistry.releaseConversation(conversationId)
)
conversationAttachmentStorage = new ConversationAttachmentStorage(app.getPath('userData'), documentParsingService.results!)
conversationAttachmentStorage.reconcile((conversationId, kind, ownerId) => startupAssistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), true)
const contextManager = new ContextManager({
parseDocument: documentParsingService.parse
parseDocument: documentParsingService.parse,
assets: conversationAttachmentStorage,
validateConversation: (id) => {
if (!startupAssistantDatabase.hasAttachmentOwner(id, 'draft', id)) throw new Error('目标会话已删除')
}
})
const approvalBroker = new ToolApprovalBroker()
@@ -1561,6 +1574,7 @@ app.on('before-quit', (event) => {
() => removeFeedbackIpcHandler?.(),
() => dshExtensionInstaller?.dispose(),
() => imageGenerationService?.dispose(),
() => knowledgeService?.beginShutdown(),
() => removeIpcHandlers?.()
],
[() => stopRuntimeReconfiguration?.()],
@@ -1583,7 +1597,9 @@ app.on('before-quit', (event) => {
).then(() => undefined),
() => embeddingModelManager?.dispose(),
() => documentOcrModelManager?.dispose(),
() => documentOcrBroker?.dispose()
() => documentOcrBroker?.dispose(),
() => documentParsingService?.dispose(),
() => conversationAttachmentStorage?.close()
],
[() => localRuntimeRegistry.dispose()],
[() => terminalSessionManager?.dispose()],
+22 -2
View File
@@ -3682,7 +3682,8 @@ describe('registerIpcHandlers document parsing', () => {
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
'diagnostic.pdf',
expect.any(Buffer),
'knowledge-index'
'knowledge-index',
expect.any(String)
)
await expect(
@@ -5260,6 +5261,8 @@ describe('registerIpcHandlers agent terminal state', () => {
})),
remove: vi.fn(),
serializeForQueue: vi.fn(() => '[]'),
hasImageInputs: vi.fn(() => false),
validateForSend: vi.fn(),
restoreFromQueue: vi.fn(),
clear: vi.fn()
}
@@ -7455,6 +7458,23 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('rejects unconfirmed image capability before accepting a queued input', async () => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
harness.contextManager.hasImageInputs.mockReturnValue(true)
try {
await expect(electronMocks.handlers.get(ipcChannels.conversationQueueEnqueueUser)?.(trustedEvent(harness.webContents), {
conversationId: '00000000-0000-4000-8000-000000000731',
runtimeSelection: { provider: 'auto' }, workMode: 'ask', prompt: 'Read the picture',
attachments: [{ id: '00000000-0000-4000-8000-000000000734', name: 'image.png', size: 10, preview: '', kind: 'image' }],
knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto'
})).rejects.toThrow('图片输入')
expect(harness.assistantDatabase.enqueueConversationUserInput).not.toHaveBeenCalled()
expect(harness.contextManager.remove).not.toHaveBeenCalled()
} finally { await harness.dispose() }
})
it('claims an idle user message before notifying the renderer and accepts its dispatch', async () => {
const runtime = {
runtimeId: 'model',
@@ -7521,7 +7541,7 @@ describe('registerIpcHandlers agent terminal state', () => {
)
harness.webContents.send.mockClear()
expect(
enqueueHandler?.(trustedEvent(harness.webContents), input)
await enqueueHandler?.(trustedEvent(harness.webContents), input)
).toEqual(item)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
+284 -26
View File
@@ -22,6 +22,8 @@ import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import { documentResourceInputSchema } from '../shared/document-result-contracts'
import { maximumAttachmentsPerMessage } from '../shared/attachment-limits'
import {
formatShortcutForDisplay,
globalShortcutSettingsUpdateSchema
@@ -1057,6 +1059,7 @@ function getKnowledgeSnapshot(
libraryId: document.knowledgeBaseId,
sourceId: document.sourceId,
name: document.title,
resultId: typeof document.metadata.parsedResultId === 'string' ? document.metadata.parsedResultId : undefined,
path: document.sourceLocation,
status: document.status,
textIndexStatus: document.textIndexStatus,
@@ -1410,6 +1413,8 @@ export function registerIpcHandlers(
let shuttingDown = false
let executionPaused = false
let clearLocalDataOperation: Promise<void> | undefined
let contextImports = 0
const diagnosticOperations = new Set<string>()
let rendererPersistenceReady = false
const pendingRendererPersistence = new Map<string, () => void>()
let pendingGoodBuddyConfigReload = false
@@ -1508,6 +1513,20 @@ export function registerIpcHandlers(
}
return selectedRuntimes.getRuntime(selection, executionSpace)
}
const assertImageInputSupport = async (request: AgentRequest): Promise<void> => {
const selected = await resolveRequestRuntime(request)
if (selected.capability === 'image-generation') return
const target = runtimeTargetFor(selected)
const configured = await settingsStore.getResolvedSettings()
const projectSelection = request.projectId ? assistantDatabase.getProject(request.projectId).runtimeSelection : undefined
const selection = request.runtimeSelection ?? projectSelection
const effective = selection ? applyRuntimeSelection(configured, selection).settings : configured
const supported = target === 'model' ? effective.supportsImageInput
: target === 'opencode' ? effective.opencodeModelProfile?.supportsImageInput
: target === 'continue' ? effective.continueModelProfile?.supportsImageInput
: target === 'deepseek-harness' ? effective.deepseekHarnessModelProfile?.supportsImageInput : false
if (supported !== true) throw new Error('当前模型与 Runtime 未确认支持图片输入。请切换模型或 Runtime、提取图片文字,或移除图片;附件保留在草稿中。')
}
const channels = Object.values(ipcChannels).filter(
(channel) =>
channel !== ipcChannels.agentEvent &&
@@ -2240,6 +2259,7 @@ export function registerIpcHandlers(
}
}
const readyConversationQueues = new Set<string>()
const conversationQueueErrors = new Map<string, string>()
const preferredConversationQueueItems = new Map<string, string>()
const reservedConversationQueueItems = new Map<string, string>()
const preparingRequestConversations = new Map<string, string>()
@@ -2355,11 +2375,16 @@ export function registerIpcHandlers(
item: claimed.item,
input: parseConversationQueueUserPayload(claimed.payloadJson, true)
}
} catch {
if (!dispatch.scheduled && contextManager.hasImageInputs(dispatch.input.attachments.map((item) => item.id))) {
await assertImageInputSupport({ ...dispatch.input, requestId: randomUUID() })
}
conversationQueueErrors.delete(claimed.item.id)
} catch (error) {
conversationQueueErrors.set(claimed.item.id, (error instanceof Error ? error.message : '队列附件无法恢复').slice(0, 1000))
assistantDatabase.releaseConversationUserQueueItem(
claimed.item.id
)
readyConversationQueues.add(conversationId)
readyConversationQueues.delete(conversationId)
publishConversationQueueChange(conversationId)
return
}
@@ -3255,7 +3280,7 @@ export function registerIpcHandlers(
accountDisplay: senderDisplay,
runtimeSelection
})
assistantDatabase.appendRemoteConversationMessage({
const incomingMessageId = assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'user',
content: parsed.prompt,
@@ -3266,6 +3291,7 @@ export function registerIpcHandlers(
: '对话'
}`
})
contextManager.assets?.reference(remoteConversation.id, 'message', incomingMessageId, contextIds)
publishRemoteConversationChange()
const remoteTaskId = randomUUID()
assistantDatabase.createTask({
@@ -3572,6 +3598,8 @@ export function registerIpcHandlers(
await executionTracker.drain()
await onBeforeClearLocalData?.()
assistantDatabase.clearAssistantData()
contextManager.clear()
contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds())
readyConversationQueues.clear()
preferredConversationQueueItems.clear()
reservedConversationQueueItems.clear()
@@ -4042,6 +4070,9 @@ export function registerIpcHandlers(
}
const imageGeneration =
selectedRuntime.capability === 'image-generation'
if (parsedRequest.contextIds?.length && contextManager.hasImageInputs(parsedRequest.contextIds)) {
await assertImageInputSupport(parsedRequest)
}
const attachedRequest = contextManager.enrichRequest(
parsedRequest
)
@@ -4285,6 +4316,7 @@ export function registerIpcHandlers(
assistantDatabase.completeConversationUserQueueItem(
parsedInput.queueItemId
)
contextManager.assets?.release('queue', parsedInput.queueItemId)
if (queuedItem?.source === 'schedule' && queuedItem.taskId) {
assistantDatabase.updateTaskStatus(queuedItem.taskId, 'running')
}
@@ -6024,7 +6056,7 @@ export function registerIpcHandlers(
if (!documentParsingService) {
throw new Error('文档解析设置服务不可用')
}
const { purpose } =
const { purpose, operationId } =
documentParsingTestInputSchema.parse(input)
const result = await dialog.showOpenDialog(window, {
title: '选择测试文档',
@@ -6048,11 +6080,16 @@ export function registerIpcHandlers(
if (!fileStat.isFile() || fileStat.size > 20 * 1024 * 1024) {
throw new Error('测试文档必须小于 20MB 且不能是目录')
}
const buffer = await readFile(canonicalPath)
const diagnosticId = operationId ?? randomUUID()
if (shuttingDown) throw new Error('应用正在退出')
diagnosticOperations.add(diagnosticId)
return documentParsingService.diagnose(
basename(canonicalPath),
await readFile(canonicalPath),
purpose
)
buffer,
purpose,
diagnosticId
).finally(() => diagnosticOperations.delete(diagnosticId))
} catch (error) {
if (error instanceof Error && !('code' in error)) {
throw error
@@ -6502,9 +6539,163 @@ export function registerIpcHandlers(
.map((connection) => ({ id: connection.id, name: connection.name, model: connection.modelName })) ?? []
}
})
registerHandler(ipcChannels.localInferenceOpenSettings, (event) => {
registerHandler(ipcChannels.documentParsingCheckHttp, (event) => {
assertTrustedSender(event, window)
window.webContents.send(ipcChannels.settingsOpen)
if (!documentParsingService) throw new Error('文档解析服务不可用')
return documentParsingService.checkHttp()
})
registerHandler(ipcChannels.documentParsingResult, (event, input: unknown) => {
assertTrustedSender(event, window)
if (!documentParsingService?.results) throw new Error('解析结果服务不可用')
return documentParsingService.results.get(documentResourceInputSchema.parse(input).id)
})
registerHandler(ipcChannels.documentParsingImage, (event, input: unknown) => {
assertTrustedSender(event, window)
if (!documentParsingService?.results) throw new Error('解析结果服务不可用')
const { id, imageId, thumbnail } = documentResourceInputSchema.parse(input)
if (!imageId) throw new Error('未选择图片')
return documentParsingService.results.image(id, imageId, thumbnail)
})
registerHandler(ipcChannels.documentParsingOriginal, async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!documentParsingService?.results) throw new Error('解析结果服务不可用')
const error = await shell.openPath(documentParsingService.results.original(documentResourceInputSchema.parse(input).id))
if (error) throw new Error('无法打开保存的原文件')
})
registerHandler(ipcChannels.documentParsingRelease, (event, input: unknown) => {
assertTrustedSender(event, window)
return documentParsingService?.results?.release(documentResourceInputSchema.parse(input).id)
})
registerHandler(ipcChannels.documentParsingCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
documentParsingService?.cancelDiagnostic(documentResourceInputSchema.parse(input).id)
})
registerHandler(ipcChannels.contextGetDraft, (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId } = z.object({ conversationId: z.string().uuid() }).strict().parse(input)
return contextManager.getDraft(conversationId)
})
registerHandler(ipcChannels.contextCancelImport, (event, input: unknown) => {
assertTrustedSender(event, window)
contextManager.cancelImport(false, assistantIdSchema.optional().parse(input))
})
let selectedImageOperation = Promise.resolve()
const attachmentParsing = new Map<string, AbortController>()
registerHandler(ipcChannels.contextImageCapability, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, runtimeSelection } = z.object({ conversationId: z.string().uuid(), runtimeSelection: agentRuntimeSelectionSchema.optional() }).strict().parse(input)
try {
const conversation = assistantDatabase.getConversation(conversationId)
await assertImageInputSupport({ requestId: randomUUID(), conversationId, projectId: conversation.projectId, runtimeSelection, prompt: '', workMode: 'ask' })
return { supported: true }
} catch (error) { return { supported: false, reason: error instanceof Error ? error.message : '尚未确认图片输入能力' } }
})
registerHandler(ipcChannels.contextCopyToDraft, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, id, operationId } = z.object({ conversationId: z.string().uuid(), id: z.string().uuid(), operationId: z.string().uuid() }).strict().parse(input)
if (!documentParsingService || !contextManager.assets) throw new Error('解析服务不可用')
const validate = (): void => { if (assistantDatabase.getConversation(conversationId).remote) throw new Error('此会话草稿不可编辑') }
validate()
if (attachmentParsing.has(operationId)) throw new Error('解析任务正在运行')
const controller = new AbortController()
attachmentParsing.set(operationId, controller)
try {
const image = Boolean(contextManager.assets.get(id).sendMode)
const attachments = await contextManager.copyToDraft(conversationId, id, (name, data) => image
? documentParsingService.extractImage(name, data, controller.signal)
: documentParsingService.parse(name, data, 'chat-attachment', controller.signal), controller.signal, validate)
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, attachments)
return attachments
} finally { attachmentParsing.delete(operationId) }
})
registerHandler(ipcChannels.contextPendingParsing, (event, input: unknown) => {
assertTrustedSender(event, window)
return contextManager.assets?.pendingParsing(assistantIdSchema.parse(input)) ?? []
})
registerHandler(ipcChannels.contextDismissParsing, (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, id } = z.object({ conversationId: z.string().uuid(), id: z.string().uuid() }).strict().parse(input)
if (!contextManager.assets?.pendingParsing(conversationId).some((item) => item.id === id)) throw new Error('解析记录不存在')
contextManager.assets.release('parsing', id)
contextManager.remove(id)
})
registerHandler(ipcChannels.contextRetryParsing, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, id, operationId } = z.object({ conversationId: z.string().uuid(), id: z.string().uuid(), operationId: z.string().uuid() }).strict().parse(input)
if (assistantDatabase.getConversation(conversationId).remote) throw new Error('此会话草稿不可编辑')
if (attachmentParsing.has(operationId)) throw new Error('解析任务正在运行')
const controller = new AbortController()
attachmentParsing.set(operationId, controller)
try {
const attachments = await contextManager.retryParsing(conversationId, id, controller.signal)
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, attachments)
return attachments
} finally { attachmentParsing.delete(operationId) }
})
registerHandler(ipcChannels.contextOpenOriginal, async (event, input: unknown) => {
assertTrustedSender(event, window)
const original = contextManager.assets?.original(assistantIdSchema.parse(input))
if (!original) throw new Error('此附件未保存原件')
const error = await shell.openPath(original.path)
if (error) throw new Error('原文件无法打开')
})
registerHandler(ipcChannels.contextCancelParsing, (event, input: unknown) => {
assertTrustedSender(event, window)
attachmentParsing.get(assistantIdSchema.parse(input))?.abort(new Error('文档解析已取消'))
})
registerHandler(ipcChannels.contextReparse, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, id, operationId } = z.object({ conversationId: z.string().uuid(), id: z.string().uuid(), operationId: z.string().uuid() }).strict().parse(input)
if (!documentParsingService || !contextManager.assets) throw new Error('文档解析服务不可用')
if (assistantDatabase.getConversation(conversationId).remote) throw new Error('此会话草稿不可编辑')
if (attachmentParsing.has(operationId)) throw new Error('任务已在运行')
const controller = new AbortController()
attachmentParsing.set(operationId, controller)
try {
const image = Boolean(contextManager.assets.get(id).sendMode)
const attachments = await contextManager.reparseDraft(conversationId, id, (name, data) => image
? documentParsingService.extractImage(name, data, controller.signal)
: documentParsingService.parse(name, data, 'chat-attachment', controller.signal), controller.signal)
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, attachments)
return attachments
} finally { attachmentParsing.delete(operationId) }
})
registerHandler(ipcChannels.contextSendOriginal, (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, id } = z.object({ conversationId: z.string().uuid(), id: z.string().uuid() }).strict().parse(input)
const attachments = contextManager.sendOriginal(conversationId, id)
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, attachments)
return attachments
})
registerHandler(ipcChannels.contextAddResultImages, (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, resultId, imageIds } = z.object({ conversationId: z.string().uuid(), resultId: z.string().uuid(), imageIds: z.array(z.string().uuid()).min(1).max(maximumAttachmentsPerMessage) }).strict().parse(input)
const operation = selectedImageOperation.then(async () => {
const conversation = assistantDatabase.getConversation(conversationId)
if (conversation.remote) throw new Error('请选择可编辑的本地或托管 SSH 会话')
if (!documentParsingService?.results) throw new Error('解析结果服务不可用')
const attachments = await contextManager.addResultImages(conversationId, resultId, imageIds, documentParsingService.results, () => {
if (assistantDatabase.getConversation(conversationId).remote) throw new Error('目标会话不可编辑')
})
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, attachments)
return attachments
})
selectedImageOperation = operation.then(() => undefined, () => undefined)
return operation
})
registerHandler(ipcChannels.contextSaveDraft, (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, ids } = z.object({ conversationId: z.string().uuid(), ids: z.array(z.string().uuid()).max(maximumAttachmentsPerMessage) }).strict().parse(input)
contextManager.saveDraft(conversationId, ids)
window.webContents.send(ipcChannels.contextDraftChanged, conversationId, contextManager.getDraft(conversationId))
})
registerHandler(ipcChannels.localInferenceOpenSettings, (event, input: unknown) => {
assertTrustedSender(event, window)
const category = z.enum(['model', 'document-parsing']).optional().parse(input) ?? 'model'
window.webContents.send(ipcChannels.settingsOpen, category)
})
registerHandler(ipcChannels.localInferenceAct, (event, input: unknown) => {
assertTrustedSender(event, window)
@@ -6867,9 +7058,11 @@ export function registerIpcHandlers(
ipcChannels.conversationsSaveLocal,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.saveLocalConversations(
localConversationSaveBatchSchema.parse(input)
)
const batch = localConversationSaveBatchSchema.parse(input)
for (const save of batch) for (const message of save.messages) {
contextManager.assets?.reference(save.header.id, 'message', message.id, message.attachments?.map((attachment) => attachment.resourceId ?? attachment.id) ?? [])
}
assistantDatabase.saveLocalConversations(batch)
}
)
@@ -6880,6 +7073,8 @@ export function registerIpcHandlers(
assistantDatabase.setConversationPinned(
conversationSetPinnedSchema.parse(input)
)
contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds())
if (contextManager.assets) contextManager.cancelUnavailableImport()
publishConversationChange()
}
)
@@ -6892,9 +7087,9 @@ export function registerIpcHandlers(
if (isConversationExecuting(parsed.sourceConversationId)) {
throw new Error('当前会话仍有正在执行的请求,请等待完成后再创建分支')
}
return assistantDatabase.branchLocalConversation(
parsed
)
const branch = assistantDatabase.branchLocalConversation(parsed)
for (const message of branch.messages) contextManager.assets?.reference(branch.id, 'message', message.id, message.attachments?.map((attachment) => attachment.resourceId ?? attachment.id) ?? [])
return branch
}
)
@@ -6923,6 +7118,8 @@ export function registerIpcHandlers(
const deleted = assistantDatabase.deleteLocalConversation(
conversationId
)
if (deleted) contextManager.assets?.deleteConversation(conversationId, contextManager.activeContextIds())
if (deleted && contextManager.assets) contextManager.cancelUnavailableImport()
preferredConversationQueueItems.delete(conversationId)
readyConversationQueues.delete(conversationId)
rendererReadyConversationQueues.delete(conversationId)
@@ -6955,21 +7152,30 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
return assistantDatabase.listConversationQueueItems(
assistantIdSchema.optional().parse(input)
)
).map((item) => conversationQueueErrors.has(item.id) ? { ...item, error: conversationQueueErrors.get(item.id) } : item)
}
)
registerHandler(
ipcChannels.conversationQueueEnqueueUser,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新消息')
}
const parsed = conversationQueueUserInputSchema.parse(input)
if (contextManager.assets) {
parsed.attachments = parsed.attachments.map((attachment) => contextManager.assets!.has(attachment.id) ? contextManager.assets!.get(attachment.id) : attachment)
}
if (contextManager.hasImageInputs(parsed.attachments.map((attachment) => attachment.id))) {
await assertImageInputSupport({
...parsed, requestId: randomUUID(), contextIds: parsed.attachments.map((attachment) => attachment.id)
})
}
const serializedContexts = contextManager.serializeForQueue(
parsed.attachments.map((attachment) => attachment.id)
)
contextManager.validateForSend(parsed.attachments.map((attachment) => attachment.id))
const item = assistantDatabase.enqueueConversationUserInput({
conversationId: parsed.conversationId,
label: parsed.prompt,
@@ -6978,6 +7184,13 @@ export function registerIpcHandlers(
serializedContexts
})
})
try {
contextManager.assets?.reference(parsed.conversationId, 'queue', item.id, parsed.attachments.map((attachment) => attachment.id))
} catch (error) {
assistantDatabase.removeConversationUserQueueItem(item.id)
throw error
}
contextManager.assets?.release('draft', parsed.conversationId)
for (const attachment of parsed.attachments) {
contextManager.remove(attachment.id)
}
@@ -7012,6 +7225,8 @@ export function registerIpcHandlers(
parseConversationQueueUserPayload(payloadJson)
}
assistantDatabase.cancelConversationQueueItem(itemId)
conversationQueueErrors.delete(itemId)
contextManager.assets?.release('queue', itemId)
if (
preferredConversationQueueItems.get(item.conversationId) ===
itemId
@@ -7031,6 +7246,36 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.conversationQueueRestoreDraft, (event, input: unknown) => {
assertTrustedSender(event, window)
const { itemId, draftText } = z.object({ itemId: z.string().uuid(), draftText: z.string().max(1_000_000) }).strict().parse(input)
const item = assistantDatabase.getConversationQueueItem(itemId)
if (!item || item.source !== 'user' || assistantDatabase.isConversationUserQueueItemDispatching(itemId)) throw new Error('此队列输入已开始执行或不可恢复')
const payload = assistantDatabase.getConversationUserQueuePayloadJson(itemId)
if (!payload) throw new Error('队列输入不存在')
const queued = parseConversationQueueUserPayload(payload, true)
const previous = contextManager.getDraft(item.conversationId)
const merged = [...previous]
for (const attachment of queued.attachments) {
if (!merged.some((current) => current.id === attachment.id || (current.provenance && attachment.provenance && current.provenance.resultId === attachment.provenance.resultId && current.provenance.imageId === attachment.provenance.imageId))) merged.push(attachment)
}
contextManager.saveDraft(item.conversationId, merged.map((attachment) => attachment.id))
try { assistantDatabase.removeConversationUserQueueItem(itemId) }
catch (error) { contextManager.saveDraft(item.conversationId, previous.map((attachment) => attachment.id)); throw error }
contextManager.assets?.release('queue', itemId)
conversationQueueErrors.delete(itemId)
const attachments = contextManager.getDraft(item.conversationId)
window.webContents.send(ipcChannels.contextDraftChanged, item.conversationId, attachments)
publishConversationQueueChange(item.conversationId)
return { conversationId: item.conversationId, prompt: [draftText, queued.prompt].filter(Boolean).join('\n\n'), attachments }
})
registerHandler(ipcChannels.conversationQueueAttachments, (event, input: unknown) => {
assertTrustedSender(event, window)
const payload = assistantDatabase.getConversationUserQueuePayloadJson(assistantIdSchema.parse(input))
if (!payload) throw new Error('队列输入已开始执行或已移除')
return parseConversationQueueUserPayload(payload).attachments
})
registerHandler(
ipcChannels.conversationQueueInterruptAndRun,
(event, input: unknown) => {
@@ -7835,8 +8080,10 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.contextSelectFiles, (event) => {
registerHandler(ipcChannels.contextSelectFiles, (event, input: unknown) => {
assertTrustedSender(event, window)
const conversationId = assistantIdSchema.optional().parse(input)
contextImports += 1
return contextManager.selectFiles(window, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send(
@@ -7844,17 +8091,18 @@ export function registerIpcHandlers(
progress
)
}
})
}, ...(conversationId ? [conversationId] as const : [])).finally(() => { contextImports -= 1 })
})
registerHandler(ipcChannels.contextImportFiles, (event, input: unknown) => {
assertTrustedSender(event, window)
const { paths } = contextImportFilesSchema.parse(input)
const { paths, conversationId } = contextImportFilesSchema.parse(input)
contextImports += 1
return contextManager.importFiles(paths, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send(ipcChannels.contextFileSelectionProgress, progress)
}
})
}, ...(conversationId ? [conversationId] as const : [])).finally(() => { contextImports -= 1 })
})
registerHandler(
@@ -7966,11 +8214,14 @@ export function registerIpcHandlers(
ipcChannels.magicNotesAnalyze,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const { entryId, requestId, direction, format, canvasImages } =
const { entryId, requestId, direction, format, canvasImages, expectedRevision } =
magicNoteAnalyzeSchema.parse(input)
const entry = assistantDatabase.getMagicNoteEntry(entryId)
const note = assistantDatabase.getMagicNoteContext(entry.noteId)
const settings = await settingsStore.getResolvedSettings()
const entry = assistantDatabase.getMagicNoteEntry(entryId)
if (expectedRevision !== undefined && entry.revision !== expectedRevision) {
throw new Error('记录已被更新,请重新捕获并分析')
}
const note = assistantDatabase.getMagicNoteContext(entry.noteId)
const analysisRuntime = createDefaultModelRuntime(
settings.workspacePath,
settings
@@ -8132,11 +8383,14 @@ export function registerIpcHandlers(
ipcChannels.magicTodosAnalyze,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const { todoId, requestId, direction, format, canvasImages } =
const { todoId, requestId, direction, format, canvasImages, sourceEntryRevision } =
magicTodoIdSchema.parse(input)
const settings = await settingsStore.getResolvedSettings()
const todo = assistantDatabase.getMagicTodo(todoId)
const entry = assistantDatabase.getMagicNoteEntry(todo.entryId)
const settings = await settingsStore.getResolvedSettings()
if (sourceEntryRevision !== undefined && entry.revision !== sourceEntryRevision) {
throw new Error('来源记录已被更新,请重新捕获并分析')
}
const analysisRuntime = createDefaultModelRuntime(
settings.workspacePath,
settings
@@ -8176,6 +8430,7 @@ export function registerIpcHandlers(
const analyzedTodo = assistantDatabase.saveMagicTodoAnalysis({
todoId,
expectedRevision: todo.revision,
sourceEntryRevision: entry.revision,
comments
})
assistantDatabase.updateTaskStatus(requestId, 'completed')
@@ -8798,6 +9053,9 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
for (const id of diagnosticOperations) documentParsingService?.cancelDiagnostic(id)
if (contextImports > 0) contextManager.cancelImport(true)
for (const controller of attachmentParsing.values()) controller.abort(new Error('应用正在退出'))
removeApplicationSettingsListener?.()
removeLocalToolEnvironmentProgressListener?.()
await localToolEnvironmentService?.dispose()
+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
)
@@ -70,7 +70,7 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
database.initialize(directory)
dispose = registerIpcHandlers(window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', { getResolvedSettings } as never, {} as never,
{ clear: vi.fn() } as never, {} as never, database,
{ clear: vi.fn(), cancelImport: vi.fn() } as never, {} as never, database,
{ clear: vi.fn() } as never, {} as never, async () => {})
}
async function reopen() {
@@ -120,6 +120,7 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
const result = await api.analyzeTodo(todo.id, {
requestId: '00000000-0000-4000-8000-000000000709',
direction: 'general', format: 'structured',
sourceEntryRevision: saved.entries[0]!.revision,
canvasImages: [{ pageId: 'page-1', dataUrl: png }]
})
expect(getResolvedSettings).toHaveBeenCalledOnce()
@@ -142,6 +143,44 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
expect(runtime.dispose).toHaveBeenCalledOnce()
})
it.each(['entry', 'todo'] as const)('rejects missing and stale screenshot revisions before %s model calls', async (kind) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput: true })
const note = await api.create({ title: 'Stale captures' })
const saved = await api.createEntry({ noteId: note.id, content: canvas() })
const entry = saved.entries[0]!
const todo = database.listMagicTodos()[0]!
const options = { requestId: '00000000-0000-4000-8000-000000000710', direction: 'general' as const,
format: 'structured' as const, canvasImages: [{ pageId: 'page-1', dataUrl: png }] }
const analyze = (revision?: number) => kind === 'entry'
? api.analyze(entry.id, { ...options, expectedRevision: revision })
: api.analyzeTodo(todo.id, { ...options, sourceEntryRevision: revision })
await expect(analyze()).rejects.toThrow('版本')
const content = canvas()
content.pages[0]!.objects[0]!.left = 500
await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
await expect(analyze(entry.revision)).rejects.toThrow('已被更新')
expect(modelFactory).not.toHaveBeenCalled()
})
it.each(['entry', 'todo'] as const)('rejects late %s analysis after a source edit with no existing comments', async (kind) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput: false })
const note = await api.create({ title: 'Late analysis' })
const saved = await api.createEntry({ noteId: note.id, content: canvas() })
const entry = saved.entries[0]!
const todo = database.listMagicTodos()[0]!
modelFactory.mockReturnValue({ dispose: vi.fn(), async *run() {
const content = canvas()
content.pages[0]!.objects[0]!.text = 'Changed context'
database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content, plainText: '' })
yield { type: 'text', delta: '{"comments":[{"kind":"summary","content":"Old result"}]}' }
yield { type: 'done' }
} })
const options = { requestId: '00000000-0000-4000-8000-000000000711', direction: 'general' as const, format: 'structured' as const }
await expect(kind === 'entry' ? api.analyze(entry.id, options) : api.analyzeTodo(todo.id, options)).rejects.toThrow('已被更新')
expect(database.getMagicTodo(todo.id).comments).toEqual([])
expect(database.getMagicNoteEntry(entry.id).comments).toEqual([])
})
it('returns the exact created ID through production IPC after an Agent append', async () => {
const note = await api.create({ title: 'Concurrent creators' })
const agent = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: 'Agent A\n' }] }, plainText: 'Agent A' })
+2 -16
View File
@@ -13,7 +13,7 @@ import type {
MagicNoteEntry,
MagicTodoItem
} from '../../shared/magic-notes-contracts'
import { magicNotePlainText } from './rich-content'
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
const structuredOutputMarker = '<<<GOODBUDDY_STRUCTURED_COMMENTS>>>'
@@ -33,21 +33,7 @@ function canvasAnalysisInput(
inputMode: MagicNoteAnalysisInputMode
} {
if (content?.version !== 2) return { inputMode: 'text' }
const flowText = (content.flow?.ops ?? [])
.map((operation) =>
typeof operation.insert === 'string' ? operation.insert : ''
)
.join('')
.trim()
const source = [
flowText ? `连续正文(跨页):\n${flowText}` : '',
...content.pages.map((page, index) => {
const text = magicNotePlainText({
...content, flow: undefined, pages: [page]
}).trim()
return text ? `第 ${index + 1} 页:\n${text}` : ''
})
].filter(Boolean).join('\n\n')
const source = magicNoteCanvasAnalysisText(content)
if (!supportsImageInput) {
return {
source,
+57
View File
@@ -0,0 +1,57 @@
import { createCanvas } from '@napi-rs/canvas'
export async function* renderOcrPdf(
buffer: Buffer,
pageNumbers: number[],
signal?: AbortSignal
): AsyncGenerator<{ pageNumber: number; data: Buffer; width: number; height: number }> {
signal?.throwIfAborted()
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
const task = getDocument({
data: Uint8Array.from(buffer), disableFontFace: true,
isOffscreenCanvasSupported: false, useSystemFonts: false, useWorkerFetch: false
})
const abort = (): void => { void task.destroy() }
signal?.addEventListener('abort', abort, { once: true })
try {
const pdf = await task.promise
for (const pageNumber of pageNumbers) {
signal?.throwIfAborted()
const page = await pdf.getPage(pageNumber)
try {
const original = page.getViewport({ scale: 1 })
const viewport = page.getViewport({ scale: Math.min(2, 2400 / Math.max(original.width, original.height)) })
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height))
const render = page.render({ canvas: canvas as unknown as Parameters<typeof page.render>[0]['canvas'], viewport })
const cancel = (): void => render.cancel()
signal?.addEventListener('abort', cancel, { once: true })
try { await render.promise } finally { signal?.removeEventListener('abort', cancel) }
signal?.throwIfAborted()
yield { pageNumber, data: await canvas.encode('png'), width: canvas.width, height: canvas.height }
} finally { page.cleanup() }
}
} finally {
signal?.removeEventListener('abort', abort)
await task.destroy()
}
}
export async function renderSelectedOcrPdf(buffer: Buffer, pageNumbers: number[], signal?: AbortSignal): Promise<Buffer> {
const { jsPDF } = await import('jspdf')
let document: InstanceType<typeof jsPDF> | undefined
let bytes = 0
for await (const page of renderOcrPdf(buffer, pageNumbers, signal)) {
bytes += page.data.length
if (bytes > 20 * 1024 * 1024) throw new Error('选中页面渲染结果超过 20 MiB,请减少单次解析页数')
const format = [page.width / 2, page.height / 2]
const orientation = page.width > page.height ? 'landscape' : 'portrait'
if (!document) document = new jsPDF({ unit: 'pt', format, orientation, compress: true })
else document.addPage(format, orientation)
document.addImage(page.data, 'PNG', 0, 0, format[0]!, format[1]!, undefined, 'FAST')
}
signal?.throwIfAborted()
if (!document) throw new Error('没有需要 OCR 的页面')
const result = Buffer.from(document.output('arraybuffer'))
if (result.length > 20 * 1024 * 1024) throw new Error('选中页面 PDF 超过 20 MiB')
return result
}