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:
@@ -158,6 +158,19 @@ export const conversationAttachmentSchema = z
|
||||
size: z.number().int().nonnegative().max(12 * 1024 * 1024),
|
||||
preview: z.string().max(500),
|
||||
kind: z.enum(['text', 'image']),
|
||||
resourceId: z.string().uuid().optional(),
|
||||
attachmentId: z.string().uuid().optional(),
|
||||
resultId: z.string().uuid().optional(),
|
||||
completeness: z.enum(['complete', 'partial', 'images-only']).optional(),
|
||||
parsingState: z.enum(['parsing', 'failed', 'interrupted']).optional(),
|
||||
parsingError: z.string().max(1000).optional(),
|
||||
originalName: z.string().max(500).optional(),
|
||||
originalSize: z.number().int().nonnegative().max(20 * 1024 * 1024).optional(),
|
||||
originalMime: z.enum(['image/png', 'image/jpeg', 'image/webp']).optional(),
|
||||
imageWidth: z.number().int().positive().optional(),
|
||||
imageHeight: z.number().int().positive().optional(),
|
||||
sendMode: z.enum(['image', 'text']).optional(),
|
||||
provenance: z.object({ resultId: z.string().uuid(), imageId: z.string().uuid(), documentName: z.string().max(500), pageNumber: z.number().int() }).optional(),
|
||||
thumbnailUrl: z
|
||||
.string()
|
||||
.max(2_000_000)
|
||||
@@ -187,6 +200,7 @@ export type ConversationAttachment = z.infer<
|
||||
|
||||
export const conversationQueueItemSchema = z
|
||||
.object({
|
||||
error: z.string().max(1000).optional(),
|
||||
id: assistantIdSchema,
|
||||
conversationId: assistantIdSchema,
|
||||
source: z.enum(['user', 'schedule']),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const maximumAttachmentsPerMessage = 8
|
||||
export const maximumContextCount = 16
|
||||
export const maximumContextBytes = 12 * 1024 * 1024
|
||||
+38
-11
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { maximumAttachmentsPerMessage, maximumContextBytes } from './attachment-limits'
|
||||
import {
|
||||
modelRequestBodySchema,
|
||||
modelRequestHeadersSchema,
|
||||
@@ -346,7 +347,7 @@ export const agentRequestSchema = z
|
||||
.max(20)
|
||||
.default([]),
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSchema.default('auto'),
|
||||
contextIds: z.array(z.string().uuid()).max(8).optional(),
|
||||
contextIds: z.array(z.string().uuid()).max(maximumAttachmentsPerMessage).optional(),
|
||||
imageContextArtifactIds: z.array(assistantIdSchema).max(8).optional(),
|
||||
history: z
|
||||
.array(conversationHistoryMessageSchema)
|
||||
@@ -388,7 +389,7 @@ export const conversationQueueUserInputSchema = z
|
||||
workMode: legacyWorkModeSchema,
|
||||
includeMemoryContext: z.boolean().default(true),
|
||||
prompt: z.string().trim().min(1).max(100_000),
|
||||
attachments: z.array(conversationAttachmentSchema).max(8).default([]),
|
||||
attachments: z.array(conversationAttachmentSchema).max(maximumAttachmentsPerMessage).default([]),
|
||||
imageContextArtifactIds: z.array(assistantIdSchema).max(8).optional(),
|
||||
knowledgeLibraryIds: z
|
||||
.array(z.string().uuid())
|
||||
@@ -1138,17 +1139,19 @@ export type RuntimeSettings = {
|
||||
export type ContextAttachment = ConversationAttachment
|
||||
|
||||
export type ContextFileSelectionProgress = {
|
||||
phase: 'reading' | 'parsing'
|
||||
phase: 'reading' | 'parsing' | 'saving'
|
||||
operationId?: string
|
||||
fileName: string
|
||||
fileNumber: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export const maximumPastedImageBytes = 12 * 1024 * 1024
|
||||
export const maximumPastedImageBytes = maximumContextBytes
|
||||
|
||||
export const clipboardTextSchema = z.string()
|
||||
|
||||
export const contextImportFilesSchema = z.object({
|
||||
conversationId: z.string().uuid().optional(),
|
||||
paths: z.array(z.string().min(1).max(32768)).min(1).max(8)
|
||||
}).strict()
|
||||
|
||||
@@ -1670,6 +1673,7 @@ export type KnowledgeSourceItem = {
|
||||
}
|
||||
|
||||
export type KnowledgeDocumentItem = {
|
||||
resultId?: string
|
||||
id: string
|
||||
libraryId: string
|
||||
sourceId?: string
|
||||
@@ -1765,7 +1769,7 @@ export type DesktopApi = {
|
||||
onBeforeQuit: (listener: () => Promise<void>) => () => void
|
||||
clearLocalData: () => Promise<void>
|
||||
onNewConversation: (listener: () => void) => () => void
|
||||
onOpenSettings: (listener: () => void) => () => void
|
||||
onOpenSettings: (listener: (category?: 'model' | 'document-parsing') => void) => () => void
|
||||
}
|
||||
clipboard: {
|
||||
readText: () => Promise<string>
|
||||
@@ -1998,13 +2002,20 @@ export type DesktopApi = {
|
||||
removeModel: (modelId: string) => Promise<EmbeddingModelSnapshot>
|
||||
}
|
||||
documentParsing?: {
|
||||
getResult: (id: string) => Promise<import('./document-result-contracts').DocumentResult>
|
||||
readResultImage: (id: string, imageId: string, thumbnail?: boolean) => Promise<string>
|
||||
openResultOriginal: (id: string) => Promise<void>
|
||||
releaseResult: (id: string) => Promise<void>
|
||||
cancelTest: (operationId: string) => Promise<void>
|
||||
checkHttp: () => Promise<{ checkedAt: string; declaredOptions: string[] }>
|
||||
getSnapshot: () => Promise<DocumentParsingSnapshot>
|
||||
getOcrModelProgress: () => Promise<DocumentOcrModelProgressSnapshot>
|
||||
update: (
|
||||
input: DocumentParsingSettings
|
||||
input: DocumentParsingSettings & { apiKey?: string; clearApiKey?: boolean }
|
||||
) => Promise<DocumentParsingSnapshot>
|
||||
test: (
|
||||
purpose: DocumentParsingTestPurpose
|
||||
purpose: DocumentParsingTestPurpose,
|
||||
operationId?: string
|
||||
) => Promise<DocumentParsingDiagnostic | undefined>
|
||||
installOcrModel: (
|
||||
modelId: string,
|
||||
@@ -2077,6 +2088,8 @@ export type DesktopApi = {
|
||||
) => () => void
|
||||
}
|
||||
conversationQueue: {
|
||||
getAttachments: (itemId: string) => Promise<ContextAttachment[]>
|
||||
restoreToDraft: (itemId: string, draftText: string) => Promise<{ conversationId: string; prompt: string; attachments: ContextAttachment[] }>
|
||||
list: (conversationId?: string) => Promise<ConversationQueueItem[]>
|
||||
enqueueUser: (
|
||||
input: ConversationQueueUserInput
|
||||
@@ -2248,9 +2261,23 @@ export type DesktopApi = {
|
||||
) => Promise<RuntimeNativeSnapshot>
|
||||
}
|
||||
context: {
|
||||
selectFiles: () => Promise<ContextAttachment[]>
|
||||
imageCapability: (conversationId: string, runtimeSelection?: AgentRuntimeSelection) => Promise<{ supported: boolean; reason?: string }>
|
||||
copyToDraft: (conversationId: string, id: string, operationId: string) => Promise<ContextAttachment[]>
|
||||
pendingParsing: (conversationId: string) => Promise<ContextAttachment[]>
|
||||
retryParsing: (conversationId: string, id: string, operationId: string) => Promise<ContextAttachment[]>
|
||||
dismissParsing: (conversationId: string, id: string) => Promise<void>
|
||||
cancelImport: (operationId?: string) => Promise<void>
|
||||
reparseDraft: (conversationId: string, id: string, operationId: string) => Promise<ContextAttachment[]>
|
||||
cancelParsing: (operationId: string) => Promise<void>
|
||||
sendOriginal: (conversationId: string, id: string) => Promise<ContextAttachment[]>
|
||||
openOriginal: (id: string) => Promise<void>
|
||||
addResultImages: (conversationId: string, resultId: string, imageIds: string[]) => Promise<ContextAttachment[]>
|
||||
onDraftChanged: (listener: (conversationId: string, attachments: ContextAttachment[]) => void) => () => void
|
||||
getDraft: (conversationId: string) => Promise<ContextAttachment[]>
|
||||
saveDraft: (conversationId: string, ids: string[]) => Promise<void>
|
||||
selectFiles: (conversationId?: string) => Promise<ContextAttachment[]>
|
||||
getFilePath: (file: File) => string
|
||||
importFiles: (paths: string[]) => Promise<ContextAttachment[]>
|
||||
importFiles: (paths: string[], conversationId?: string) => Promise<ContextAttachment[]>
|
||||
onFileSelectionProgress: (
|
||||
listener: (progress: ContextFileSelectionProgress) => void
|
||||
) => () => void
|
||||
@@ -2278,7 +2305,7 @@ export type DesktopApi = {
|
||||
removeEntry: (entryId: string) => Promise<MagicNoteDetail>
|
||||
analyze: (
|
||||
entryId: string,
|
||||
options: MagicNoteAnalysisOptions
|
||||
options: import('./magic-notes-contracts').MagicNoteEntryAnalysisOptions
|
||||
) => Promise<MagicNoteDetail>
|
||||
analyzeDraft: (
|
||||
content: MagicNoteContent,
|
||||
@@ -2291,7 +2318,7 @@ export type DesktopApi = {
|
||||
) => Promise<MagicTodoUpdateResult>
|
||||
analyzeTodo: (
|
||||
todoId: string,
|
||||
options: MagicNoteAnalysisOptions
|
||||
options: import('./magic-notes-contracts').MagicTodoAnalysisOptions
|
||||
) => Promise<MagicTodoItem>
|
||||
onAnalysisEvent: (
|
||||
listener: (event: MagicNoteAnalysisStreamEvent) => void
|
||||
|
||||
@@ -247,18 +247,71 @@ export const documentOcrModelInstallInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const httpOcrOptionsSchema = z.object({
|
||||
useDocOrientationClassify: z.boolean().optional(),
|
||||
useDocUnwarping: z.boolean().optional(),
|
||||
useLayoutDetection: z.boolean().optional(),
|
||||
useChartRecognition: z.boolean().optional(),
|
||||
useSealRecognition: z.boolean().optional(),
|
||||
useOcrForImageBlock: z.boolean().optional(),
|
||||
prettifyMarkdown: z.boolean().optional(),
|
||||
showFormulaNumber: z.boolean().optional(),
|
||||
formatBlockContent: z.boolean().optional(),
|
||||
mergeLayoutBlocks: z.boolean().optional(),
|
||||
layoutThreshold: z.number().min(0).max(1).optional(),
|
||||
layoutUnclipRatio: z.number().positive().optional(),
|
||||
layoutMergeBboxesMode: z.string().trim().min(1).max(64).optional(),
|
||||
layoutNms: z.boolean().optional(),
|
||||
layoutShapeMode: z.enum(['rect', 'quad', 'poly', 'auto']).optional(),
|
||||
promptLabel: z.string().max(500).optional(),
|
||||
repetitionPenalty: z.number().positive().optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
topP: z.number().min(0).max(1).optional(),
|
||||
minPixels: z.number().int().positive().optional(),
|
||||
maxPixels: z.number().int().positive().optional(),
|
||||
maxNewTokens: z.number().int().positive().optional()
|
||||
}).strict()
|
||||
|
||||
export const httpOcrSettingsSchema = z.object({
|
||||
baseUrl: z.string().trim().max(2048).refine((value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash
|
||||
} catch { return false }
|
||||
}, '请输入不含凭据、查询参数或片段的 HTTP 服务地址'),
|
||||
authentication: z.enum(['none', 'bearer']),
|
||||
timeoutSeconds: z.number().int().min(10).max(300),
|
||||
filterMode: z.enum(['default', 'all', 'custom']),
|
||||
ignoredLabels: z.array(z.string().trim().min(1).max(100)).max(100),
|
||||
options: httpOcrOptionsSchema,
|
||||
mergeTables: z.boolean(),
|
||||
relevelTitles: z.boolean()
|
||||
}).strict()
|
||||
|
||||
export const defaultHttpOcrSettings = httpOcrSettingsSchema.parse({
|
||||
baseUrl: '', authentication: 'none', timeoutSeconds: 60,
|
||||
filterMode: 'default', ignoredLabels: [], options: {},
|
||||
mergeTables: false, relevelTitles: false
|
||||
})
|
||||
|
||||
export const documentParsingSettingsSchema = z
|
||||
.object({
|
||||
chatWorkflow: chatDocumentWorkflowSchema,
|
||||
knowledgeWorkflow: knowledgeDocumentWorkflowSchema,
|
||||
localOcrModelId: localOcrModelIdSchema,
|
||||
maximumPages: z.number().int().min(1).max(500),
|
||||
pageTimeoutSeconds: z.number().int().min(10).max(300)
|
||||
pageTimeoutSeconds: z.number().int().min(10).max(300),
|
||||
ocrProvider: z.enum(['local', 'paddleocr-vl']).optional(),
|
||||
httpOcr: httpOcrSettingsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentParsingSettingsUpdateSchema =
|
||||
documentParsingSettingsSchema
|
||||
documentParsingSettingsSchema.extend({
|
||||
apiKey: z.string().trim().max(4096).optional(),
|
||||
clearApiKey: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const documentParsingModelStatusSchema = z
|
||||
.object({
|
||||
@@ -284,18 +337,22 @@ export const documentParsingSnapshotSchema = z
|
||||
settings: documentParsingSettingsSchema,
|
||||
status: documentParsingStatusSchema,
|
||||
ocrModels: documentOcrModelSnapshotSchema,
|
||||
httpCredentialConfigured: z.boolean().optional(),
|
||||
warnings: settingsWarningsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentParsingTestInputSchema = z
|
||||
.object({
|
||||
purpose: documentParsingTestPurposeSchema
|
||||
purpose: documentParsingTestPurposeSchema,
|
||||
operationId: z.string().uuid().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentParsingDiagnosticSchema = z
|
||||
.object({
|
||||
provider: z.enum(['native', 'local', 'paddleocr-vl']).optional(),
|
||||
resultId: z.string().uuid().optional(),
|
||||
fileName: z.string().trim().min(1).max(500),
|
||||
sourceFormat: z.string().trim().min(1).max(32),
|
||||
pageCount: z.number().int().nonnegative().max(maximumPdfPageCount),
|
||||
@@ -381,7 +438,7 @@ export const documentOcrSectionSchema = z
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(maximumDocumentOcrSectionCharacters),
|
||||
confidence: z.number().min(0).max(1)
|
||||
confidence: z.number().min(0).max(1).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
import { documentParsingSettingsSchema } from './document-parsing-contracts'
|
||||
|
||||
export const documentResultSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
fileName: z.string().min(1).max(500),
|
||||
sourceFormat: z.string(),
|
||||
content: z.string().max(5_000_000),
|
||||
sections: z.array(z.object({
|
||||
locator: z.string(), content: z.string(), pageNumber: z.number().int().optional(),
|
||||
sourcePages: z.array(z.number().int()).optional(), confidence: z.number().optional(),
|
||||
method: z.string().optional()
|
||||
})),
|
||||
images: z.array(z.object({
|
||||
id: z.string().uuid(), pageNumber: z.number().int(), key: z.string(),
|
||||
locator: z.string().optional(),
|
||||
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
||||
width: z.number().int(), height: z.number().int(), size: z.number().int()
|
||||
})),
|
||||
missingImages: z.array(z.object({ pageNumber: z.number().int(), key: z.string(), reason: z.string() })).default([]),
|
||||
pageCount: z.number().int().optional(),
|
||||
warnings: z.array(z.string()),
|
||||
completeness: z.enum(['complete', 'partial', 'images-only']),
|
||||
parsedAt: z.string(), durationMs: z.number().nonnegative(),
|
||||
settings: documentParsingSettingsSchema,
|
||||
restructure: z.object({ changed: z.boolean(), sourcePages: z.array(z.number()) }).optional()
|
||||
}).strict()
|
||||
export type DocumentResult = z.infer<typeof documentResultSchema>
|
||||
|
||||
export const documentResourceInputSchema = z.object({
|
||||
id: z.string().uuid(), imageId: z.string().uuid().optional(), thumbnail: z.boolean().optional()
|
||||
}).strict()
|
||||
@@ -158,6 +158,28 @@ export const ipcChannels = {
|
||||
'settings:embedding-models:import-archive',
|
||||
embeddingModelsRemove: 'settings:embedding-models:remove',
|
||||
documentParsingGet: 'settings:document-parsing:get',
|
||||
documentParsingCheckHttp: 'settings:document-parsing:check-http',
|
||||
documentParsingResult: 'document-parsing:result',
|
||||
documentParsingImage: 'document-parsing:image',
|
||||
documentParsingOriginal: 'document-parsing:original',
|
||||
documentParsingRelease: 'document-parsing:release',
|
||||
documentParsingCancel: 'document-parsing:cancel',
|
||||
contextGetDraft: 'context:get-draft',
|
||||
contextSaveDraft: 'context:save-draft',
|
||||
contextAddResultImages: 'context:add-result-images',
|
||||
contextDraftChanged: 'context:draft-changed',
|
||||
contextReparse: 'context:reparse',
|
||||
contextCancelParsing: 'context:cancel-parsing',
|
||||
contextSendOriginal: 'context:send-original',
|
||||
contextOpenOriginal: 'context:open-original',
|
||||
contextCancelImport: 'context:cancel-import',
|
||||
contextPendingParsing: 'context:pending-parsing',
|
||||
contextRetryParsing: 'context:retry-parsing',
|
||||
contextDismissParsing: 'context:dismiss-parsing',
|
||||
contextCopyToDraft: 'context:copy-to-draft',
|
||||
contextImageCapability: 'context:image-capability',
|
||||
conversationQueueRestoreDraft: 'conversation-queue:restore-draft',
|
||||
conversationQueueAttachments: 'conversation-queue:attachments',
|
||||
documentOcrModelsProgress:
|
||||
'settings:document-ocr-models:progress',
|
||||
documentParsingUpdate: 'settings:document-parsing:update',
|
||||
|
||||
@@ -49,7 +49,7 @@ export type LocalInferenceSnapshot = {
|
||||
externalConnections?: { id: string; name: string; model: string }[]
|
||||
}
|
||||
export type LocalInferenceApi = {
|
||||
openSettings: () => Promise<void>
|
||||
openSettings: (category?: 'model' | 'document-parsing') => Promise<void>
|
||||
getSnapshot: () => Promise<LocalInferenceSnapshot>
|
||||
act: (input: InferenceAction) => Promise<void>
|
||||
cancel: (taskId: string) => Promise<void>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { MagicNoteCanvasContent } from './magic-notes-contracts'
|
||||
|
||||
export function magicNoteCanvasAnalysisText(content: MagicNoteCanvasContent): string {
|
||||
const flowText = (content.flow?.ops ?? [])
|
||||
.map(({ insert }) => typeof insert === 'string' ? insert : '')
|
||||
.join('').trim()
|
||||
return [
|
||||
flowText ? `连续正文(跨页):\n${flowText}` : '',
|
||||
...content.pages.map((page, index) => {
|
||||
const text = magicNoteCanvasPlainText({ ...content, flow: undefined, pages: [page] })
|
||||
return text ? `第 ${index + 1} 页:\n${text}` : ''
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
// Keep canvas text comparisons identical in the renderer and persistence layer.
|
||||
export function magicNoteCanvasPlainText(content: MagicNoteCanvasContent): string {
|
||||
const flow = (content.flow?.ops ?? []).map(({ insert }) => {
|
||||
|
||||
@@ -370,9 +370,15 @@ export type MagicNoteAnalysisOptions = z.infer<
|
||||
export const magicNoteAnalyzeSchema = z
|
||||
.object({
|
||||
entryId: magicNoteIdSchema,
|
||||
expectedRevision: z.number().int().nonnegative().optional(),
|
||||
...magicNoteAnalysisOptionsSchema.shape
|
||||
})
|
||||
.strict()
|
||||
.refine((input) => !input.canvasImages?.length || input.expectedRevision !== undefined, {
|
||||
message: '画布截图必须提供记录版本', path: ['expectedRevision']
|
||||
})
|
||||
|
||||
export type MagicNoteEntryAnalysisOptions = MagicNoteAnalysisOptions & { expectedRevision?: number }
|
||||
|
||||
export const magicNoteDraftAnalyzeSchema = z
|
||||
.object({
|
||||
@@ -384,9 +390,15 @@ export const magicNoteDraftAnalyzeSchema = z
|
||||
export const magicTodoIdSchema = z
|
||||
.object({
|
||||
todoId: magicNoteIdSchema,
|
||||
sourceEntryRevision: z.number().int().nonnegative().optional(),
|
||||
...magicNoteAnalysisOptionsSchema.shape
|
||||
})
|
||||
.strict()
|
||||
.refine((input) => !input.canvasImages?.length || input.sourceEntryRevision !== undefined, {
|
||||
message: '画布截图必须提供来源记录版本', path: ['sourceEntryRevision']
|
||||
})
|
||||
|
||||
export type MagicTodoAnalysisOptions = MagicNoteAnalysisOptions & { sourceEntryRevision?: number }
|
||||
|
||||
export const magicTodoUpdateSchema = z
|
||||
.object({
|
||||
|
||||
Reference in New Issue
Block a user