feat: configure canvas pages sent for note analysis

Canvas analysis previously sent every page. Add a Magic Notes setting for
1-8 canvas pages, defaulting to 1 for new and historical settings. Select
the first N pages in current canvas order before capture and use the same
text/image range for saved entries, drafts and canvas-source todos.

Keep full 50-page saving/export, expose the same setting through config
tools, and use the shared settings select with an inline help icon.
Include pagination, persistence, production IPC and isolated Electron
regressions, including text-only input and scaled page boundaries.

Validation: focused feature tests and isolated Electron regressions passed;
the final compact UI passed 14 tests. Typecheck and lint were reported
passing by the coordinating agent. No full-suite pass is claimed because
the completed full test command exposed no summary. No live model calls
were made for this commit preparation.

Release note: 魔法笔记新增“发送画布页数”设置,可选 1~8 页,默认 1 页,按当前画布顺序发送前 N 页的文字和图片;标题旁问号可查看说明,保存和导出完整画布不受影响。
This commit is contained in:
mesalogo
2026-09-20 00:07:07 +08:00
parent ecf09f4e95
commit bf1d34e897
39 changed files with 475 additions and 102 deletions
+13 -4
View File
@@ -10,7 +10,7 @@ import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('analyzes saved, edited, created and todo canvases through production Electron IPC', async () => {
it.each([true, false])('analyzes selected canvas pages through production Electron UI and IPC (vision=%s)', async (supportsImageInput) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-analysis-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-analysis', configureServer(server) {
@@ -30,7 +30,7 @@ it('analyzes saved, edited, created and todo canvases through production Electro
build.onLoad({ filter: /.*/, namespace: 'probe' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[]; export const isReadOnlyChannelMessage=()=>true;'
: `export const createDefaultModelRuntime=()=>({ dispose:async()=>{}, releaseConversation:async()=>{}, async *run(request) {
if (!request.images?.length) throw new Error('Expected real canvas captures');
(globalThis.analysisRequests ??= []).push({ prompt: request.prompt, imageCount: request.images?.length ?? 0 });
yield {requestId:request.requestId,type:'text',delta:'{"comments":[{"kind":"summary","content":"Canvas reviewed."}]}'};
yield {requestId:request.requestId,type:'done'};
}}); export const createModelProfileRuntime=createDefaultModelRuntime;` }))
@@ -45,7 +45,7 @@ it('analyzes saved, edited, created and todo canvases through production Electro
await server.listen()
const bootstrap = join(directory, 'bootstrap.cjs')
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
const env: NodeJS.ProcessEnv = { ...process.env, GB_NOTES_PROBE_DIRECTORY: directory, GB_NOTES_PROBE_URL: server.resolvedUrls!.local[0] + 'analysis.html' }
const env: NodeJS.ProcessEnv = { ...process.env, GB_NOTES_PROBE_VISION: String(supportsImageInput), GB_NOTES_PROBE_DIRECTORY: directory, GB_NOTES_PROBE_URL: server.resolvedUrls!.local[0] + 'analysis.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
@@ -56,7 +56,16 @@ it('analyzes saved, edited, created and todo canvases through production Electro
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
expect(result).toEqual({ errors: [], manualRevision: 1, editedRevision: 3, createdAnalyzed: true, todoAnalyzed: 'canvas-images' })
expect(result).toMatchObject({ errors: [], manualRevision: 1, editedRevision: 3, createdAnalyzed: true, todoAnalyzed: supportsImageInput ? 'canvas-images' : 'text-fallback' })
expect(result.requests).toHaveLength(6)
for (const request of result.requests.slice(0, 5)) {
expect(request.imageCount).toBe(supportsImageInput ? 2 : 0)
expect(request.prompt).toContain('Included second')
expect(request.prompt).not.toContain('Excluded third')
}
expect(result.requests[5].prompt).toContain('Automatic start')
expect(result.requests[5].prompt).not.toContain('Automatic excluded tail')
expect(result.requests[5].imageCount).toBe(supportsImageInput ? 2 : 0)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
+8 -4
View File
@@ -3,6 +3,7 @@ import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
const directory = process.env.GB_NOTES_PROBE_DIRECTORY!
const url = process.env.GB_NOTES_PROBE_URL!
@@ -14,13 +15,16 @@ app.whenReady().then(async () => {
} })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const runtimeSettings = { workspacePath: directory, supportsImageInput: true,
defaultModelProfileId: 'vision', modelProfiles: [{ id: 'vision', supportsImageInput: true }] }
const supportsImageInput = process.env.GB_NOTES_PROBE_VISION === 'true'
const runtimeSettings = { workspacePath: directory, supportsImageInput,
defaultModelProfileId: 'vision', modelProfiles: [{ id: 'vision', supportsImageInput }] }
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' })
const dispose = registerIpcHandlers(win, { capability: 'text' } as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => runtimeSettings } as never,
{} as never, { clear() {}, cancelImport() {} } as never, {} as never, database,
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined,
{ get: async () => ({ magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }), onChanged: () => () => {} } as never)
applicationSettings)
win.webContents.on('console-message', event => console.log(event.message))
try {
await win.loadURL(url)
@@ -29,7 +33,7 @@ app.whenReady().then(async () => {
await new Promise(resolve => setTimeout(resolve, 50))
}
const result = await win.webContents.executeJavaScript('window.notesAnalysisRegression().catch(error => ({ error: error.stack, body: document.body.innerText }))')
writeFileSync(join(directory, 'result.json'), JSON.stringify(result))
writeFileSync(join(directory, 'result.json'), JSON.stringify({ ...result, requests: (globalThis as typeof globalThis & { analysisRequests: unknown[] }).analysisRequests }))
} finally {
await dispose()
database.close()
@@ -1,5 +1,8 @@
import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { useState } from 'react'
import { ApplicationSettingsView } from '../../src/renderer/src/ApplicationCenter'
import type { ApplicationSettings } from '../../src/shared/application-settings-contracts'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contracts'
import i18n from '../../src/renderer/src/i18n'
@@ -9,17 +12,28 @@ import '../../src/renderer/src/magic-canvas.css'
async function notesAnalysisRegression() {
await i18n.changeLanguage('en-US')
const api = window.goodbuddy.magicNotes
const initialSettings = await window.goodbuddy.updates.getSettings()
if (initialSettings.magicNoteCanvasPageCount !== 1) throw new Error('Expected default page count 1')
const inputMode = (await window.goodbuddy.settings.getRuntime()).supportsImageInput ? 'canvas-images' : 'text-fallback'
const note = await api.create({ title: 'IPC canvas analysis' })
const content: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas', assets: [],
pages: [{ id: 'page-1', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }],
flow: { version: 1, ops: [{ insert: 'Review task' }, { insert: '\n', attributes: { list: 'unchecked' } }] } }
pages: [1, 2, 3].map(index => ({ id: `page-${index}`, width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] })),
flow: { version: 1, ops: [{ insert: 'Review task' }, { insert: '\n', attributes: { list: 'unchecked' } },
{ insert: { canvasPageBreak: 'page-2' } }, { insert: 'Included second\n' },
{ insert: { canvasPageBreak: 'page-3' } }, { insert: 'Excluded third\n' }] } }
const seeded = await api.createEntry({ noteId: note.id, content })
const entryId = seeded.createdEntryId
const host = document.createElement('div')
document.body.append(host)
const errors: string[] = []
const root = createRoot(host)
root.render(<MagicNotesWorkspace onNotify={notification => { if (notification.tone === 'error') errors.push(notification.message) }} />)
function Probe() {
const [settings, setSettings] = useState<ApplicationSettings>(initialSettings)
return <><ApplicationSettingsView id="magic-notes" settings={settings} pending={false} onUpdate={async updates => {
setSettings(await window.goodbuddy.updates.updateSettings(updates)); return true
}} /><MagicNotesWorkspace applicationSettings={settings} onNotify={notification => { if (notification.tone === 'error') errors.push(notification.message) }} /></>
}
root.render(<Probe />)
const wait = async (predicate: () => unknown | Promise<unknown>) => {
for (let i = 0; i < 400; i++) {
if (await predicate()) return
@@ -34,10 +48,22 @@ async function notesAnalysisRegression() {
button(label, parent).click()
}
const entry = async () => (await api.get(note.id)).entries.find(item => item.id === entryId)!
await wait(() => host.querySelector('select[aria-describedby="magic-note-canvas-page-count-help"]'))
const pageSelect = host.querySelector<HTMLSelectElement>('select[aria-describedby="magic-note-canvas-page-count-help"]')!
pageSelect.value = '2'
pageSelect.dispatchEvent(new Event('change', { bubbles: true }))
await wait(async () => (await window.goodbuddy.updates.getSettings()).magicNoteCanvasPageCount === 2)
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
const nextPage = () => host.querySelector<HTMLButtonElement>('.magic-note-entry .canvas-note-next-page')!
await wait(() => nextPage() && !nextPage().disabled)
nextPage().click()
await wait(() => nextPage() && !nextPage().disabled)
nextPage().click()
await wait(() => nextPage()?.disabled)
host.querySelector<HTMLButtonElement>('.magic-note-entry .canvas-note-zoom-out')!.click()
await click('Analyze with AI')
await wait(async () => (await entry()).comments[0]?.inputMode === 'canvas-images')
await wait(async () => (await entry()).comments[0]?.inputMode === inputMode)
const manualRevision = (await entry()).revision
await click('Expand canvas editor')
const editor = () => host.querySelector<HTMLElement>('.magic-note-entry__editor')!
@@ -56,16 +82,29 @@ async function notesAnalysisRegression() {
await wait(() => composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
await wait(() => composer().querySelector('.canvas-flow-quill'))
;(Quill.find(composer().querySelector('.canvas-flow-quill')!) as Quill).setText('Created canvas\n', 'user')
;(Quill.find(composer().querySelector('.canvas-flow-quill')!) as Quill).setContents(content.flow!.ops, 'user')
await click('Analyze canvas draft', composer())
await wait(() => button('Analyze canvas draft', composer())?.disabled === false)
await click('Save entry', composer())
await wait(async () => (await api.get(note.id)).entries.some(item => item.id !== entryId && item.comments[0]?.inputMode === 'canvas-images'))
await wait(async () => (await api.get(note.id)).entries.some(item => item.id !== entryId && item.comments[0]?.inputMode === inputMode))
await click('Back to overview')
await click('Switch to to-dos')
await wait(() => host.querySelector<HTMLButtonElement>('.magic-todo-list-item__content'))
host.querySelector<HTMLButtonElement>('.magic-todo-list-item__content')!.click()
await click('Analyze with AI')
await wait(async () => (await api.listTodos()).todos[0]?.comments[0]?.inputMode === 'canvas-images')
await wait(async () => (await api.listTodos()).todos[0]?.comments[0]?.inputMode === inputMode)
const todo = (await api.listTodos()).todos[0]!
// Automatic CSS-column pagination must obey the same boundary without embeds.
await click('Switch to notes')
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
await wait(() => composer())
await wait(() => composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
const autoFlow = Quill.find(composer().querySelector('.canvas-flow-quill')!) as Quill
autoFlow.setText('Automatic start\n' + 'Flow line\n'.repeat(180) + 'Automatic excluded tail\n', 'user')
await click('Analyze canvas draft', composer())
await wait(() => button('Analyze canvas draft', composer())?.disabled === false)
const result = { errors, manualRevision, editedRevision, createdAnalyzed: true, todoAnalyzed: todo.comments[0]?.inputMode }
root.unmount()
host.remove()