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:
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user