fix: keep question order and streamline contextual help
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Keep interactive questions and answered cards at their original position in
conversation history, including remote reception, recovery, and reopening.
Preserve legacy history placement without a database migration.

Move supplementary guidance into shared accessible contextual-help popovers
across pages and settings. Add keyboard, modal, accessibility, and Electron
regressions, plus bounded opt-in live canvas-analysis validation.

Remove unused Agent, Runtime, and in-memory graph-search helpers, obsolete
mocks and browser copy; reuse the existing package-size formatter. Keep
production execution paths, protocol schemas, and released data migrations.

Validation: targeted cleanup tests, typecheck, lint, shared Linux Host
attach/read/re-attach, and added help/Electron checks passed. Final full-suite
verification passed 4,858 tests with 67 skipped and zero failures (412 files
passed, 9 skipped). Focused help checks passed 54 tests, including Electron
style comparisons and Chromium accessibility descriptions. Details and
validation boundaries are recorded in feature docs.

Release note: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
This commit is contained in:
mesalogo
2026-09-20 01:26:36 +08:00
parent bf1d34e897
commit 9836a4cc9d
89 changed files with 1961 additions and 1111 deletions
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('keeps inline help usable in real modals with mouse, keyboard and narrow windows', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-inline-help-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'inline-help', configureServer(server) {
server.middlewares.use('/inline-help.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/inline-help.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/inline-help-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_HELP_URL: server.resolvedUrls!.local[0] + 'inline-help.html',
GOODBUDDY_HELP_DIRECTORY: directory }
delete env.ELECTRON_RUN_AS_NODE
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/inline-help-driver.mjs'), driver)
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
process.stdout.write(output.split(/\r?\n/).filter(line => line.startsWith('InlineHelp')).join('\n') + '\n')
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 90000)
+28 -7
View File
@@ -10,7 +10,9 @@ import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it.each([true, false])('analyzes selected canvas pages through production Electron UI and IPC (vision=%s)', async (supportsImageInput) => {
const live = process.env.GB_NOTES_LIVE === '1'
const livePageCounts = process.env.GB_NOTES_LIVE_PAGES === '8' ? [8] : [1, 8]
it.each(live ? [true] : [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) {
@@ -26,9 +28,9 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
await build({ entryPoints: ['tests/support/magic-notes-analysis-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'],
plugins: [{ name: 'isolated-services', setup(build) {
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'probe' }))
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'probe' }))
if (!live) build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'probe' }))
build.onLoad({ filter: /.*/, namespace: 'probe' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[]; export const isReadOnlyChannelMessage=()=>true;'
? 'export const startEnvironmentChannels=()=>[];'
: `export const createDefaultModelRuntime=()=>({ dispose:async()=>{}, releaseConversation:async()=>{}, async *run(request) {
(globalThis.analysisRequests ??= []).push({ prompt: request.prompt, imageCount: request.images?.length ?? 0 });
yield {requestId:request.requestId,type:'text',delta:'{"comments":[{"kind":"summary","content":"Canvas reviewed."}]}'};
@@ -45,17 +47,35 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
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_VISION: String(supportsImageInput), 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' + (live ? `?live=1&pages=${process.env.GB_NOTES_LIVE_PAGES ?? ''}` : '') }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
const timeout = setTimeout(() => child.kill(), live ? 240000 : 60000)
try {
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'))
if (live) {
const evidence = JSON.parse(await readFile(join(directory, 'live-evidence.json'), 'utf8'))
expect(result.error).toBeUndefined()
expect(result.errors).toEqual([])
expect(result.runs.map((run: { pageCount: number }) => run.pageCount)).toEqual(livePageCounts)
expect(evidence.realCallCount).toBe(livePageCounts.length)
expect(evidence.originalSettingsUnchanged).toBe(true)
expect(evidence.requests.map((request: { imageCount: number }) => request.imageCount)).toEqual(livePageCounts)
for (const [index, run] of result.runs.entries()) {
expect(run.comments.every((comment: { inputMode: string }) => comment.inputMode === 'canvas-images')).toBe(true)
const comments = run.comments.map((comment: { content: string }) => comment.content).join('\n')
expect(comments).toContain(result.markers[0])
expect(comments).not.toContain(result.markers[8])
expect(evidence.requests[index].status).toBe(200)
for (const marker of result.markers) expect(evidence.requests[index].text).not.toContain(marker)
}
return
}
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)) {
@@ -68,6 +88,7 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
expect(result.requests[5].imageCount).toBe(supportsImageInput ? 2 : 0)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
if (live) console.log(`Live notes evidence: ${directory}`)
else await rm(directory, { recursive: true, force: true })
}
}, 90000)
}, live ? 270000 : 90000)
+239
View File
@@ -0,0 +1,239 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
app.setPath('userData', join(process.env.GOODBUDDY_HELP_DIRECTORY, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1000, height: 700, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
const js = async code => {
try { return await win.webContents.executeJavaScript(code) }
catch (error) { throw new Error('Renderer script: ' + code, { cause: error }) }
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const wait = async code => {
for (let i = 0; i < 200; i++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error('Timeout: ' + code)
}
// scrollIntoView also scrolls overflow:hidden <details>, unlike a user's wheel.
const reveal = async selector => {
await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
for (let p = e.parentElement; p; p = p.parentElement) {
if (!/(auto|scroll)/.test(getComputedStyle(p).overflowY)) continue;
const r = e.getBoundingClientRect(), bounds = p.getBoundingClientRect();
p.scrollTop += r.top + r.height/2 - bounds.top - p.clientHeight/2;
} })()`)
await settle()
}
const mouse = async (selector, click = false) => {
await reveal(selector)
const point = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); const r = e.getBoundingClientRect();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
const parents = []; for (let p = e; p && parents.length < 9; p = p.parentElement) {
const b = p.getBoundingClientRect(); parents.push({ tag: p.tagName, class: p.className, inert: p.inert,
top: b.top, height: b.height, scrollTop: p.scrollTop, overflow: getComputedStyle(p).overflow, pointer: getComputedStyle(p).pointerEvents }); }
return { x, y, hit: e.contains(document.elementFromPoint(x,y)), target: document.elementFromPoint(x,y)?.outerHTML.slice(0, 250), parents }; })()`)
assert(point.hit, 'Occluded: ' + selector + ' ' + JSON.stringify(point))
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
if (click) {
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
}
await settle()
}
const key = async keyCode => {
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await settle()
}
await win.loadURL(process.env.GOODBUDDY_HELP_URL)
await wait('!!document.querySelector("#open")')
win.focus()
win.webContents.focus()
await settle()
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1000, 700], [360, 320]]) {
win.setContentSize(width, height)
await settle()
await mouse('#open', true)
await wait('!!document.querySelector("#outside")')
assert(await js('document.querySelector(".app-shell").inert'))
await mouse('[aria-label="Page"]')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.id === "outside"'), 'Hover stole focus')
assert(await js('document.querySelector("[aria-modal=true]").contains(document.querySelector("[role=tooltip]"))'))
await mouse('[role=tooltip]')
await setTimeout(160)
assert(await js('!!document.querySelector("[role=tooltip]")'), 'Hover transfer closed help')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
assert(await js('!!document.querySelector("[aria-modal=true]")'), `Hover Escape closed modal: ${theme} ${width}x${height}`)
await mouse('#outside')
await key('Tab')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.matches(".inline-help")'))
await key('Escape')
await key('Space')
await wait('!!document.querySelector("[role=tooltip]")')
await key('Space')
assert(await js('!document.querySelector("[role=tooltip]")'))
await mouse('[aria-label="Long help"]', true)
await wait('!!document.querySelector("#long-help[role=tooltip]")')
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await setTimeout(160)
assert(await js('!!document.querySelector("#long-help[role=tooltip]")'), 'Click did not pin help')
assert(await js(`(() => { const e = document.querySelector('#long-help'), r = e.getBoundingClientRect();
return r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15 && e.scrollWidth <= e.clientWidth; })()`), 'Help overflowed viewport')
win.webContents.sendInputEvent({ type: 'mouseDown', x: 5, y: 5, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: 5, y: 5, button: 'left', clickCount: 1 })
await settle()
assert(await js('!document.querySelector("[role=tooltip]")'))
await key('Escape')
await wait('!document.querySelector("[aria-modal=true]")')
assert(await js('document.activeElement.id === "open"'))
}
}
console.log('InlineHelp: hover/focus/click/Escape/outside/modal/narrow bounds passed in light and dark themes')
win.webContents.debugger.attach('1.3')
const evidence = []
const artifacts = process.env.GOODBUDDY_HELP_ARTIFACTS
if (artifacts) await mkdir(artifacts, { recursive: true })
let capturePrefix = ''
let captureIndex = 0
const snapshot = root => js(`(() => { const root = document.querySelector(${JSON.stringify(root)});
return JSON.stringify({ fields: Array.from(root.querySelectorAll('input, select, textarea')).map(e => [e.id, e.value, e.checked]),
details: Array.from(root.querySelectorAll('details')).map(e => e.open),
tabs: Array.from(root.querySelectorAll('[aria-selected], [aria-pressed]')).map(e => [e.textContent, e.getAttribute('aria-selected'), e.getAttribute('aria-pressed')]) }); })()`)
const description = async selector => {
const expected = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
const id = e.getAttribute('aria-describedby'); const targets = Array.from(document.querySelectorAll('[id]')).filter(e => e.id === id);
if (targets.length !== 1) throw new Error('Missing or duplicate description: ' + id);
return targets[0].textContent.replace(/\\s+/g, ' ').trim(); })()`)
assert(expected.length > 0)
const { root } = await win.webContents.debugger.sendCommand('DOM.getDocument')
const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector })
const { nodes } = await win.webContents.debugger.sendCommand('Accessibility.getPartialAXTree', { nodeId })
assert.equal(nodes[0].description?.value, expected, 'Chromium accessibility description: ' + selector)
}
const checkHelp = async (selector, root, describedControl) => {
await reveal(selector)
if (describedControl) await description(describedControl)
const before = await snapshot(root)
const geometry = await js(`(() => { const button = document.querySelector(${JSON.stringify(selector)});
const previous = button.previousSibling; const range = document.createRange(); range.selectNodeContents(previous);
const label = range.getBoundingClientRect(), r = button.getBoundingClientRect();
const style = getComputedStyle(button), svg = button.querySelector('svg'), icon = getComputedStyle(svg);
return { label: button.getAttribute('aria-label'), sameLine: Math.min(label.bottom, r.bottom) > Math.max(label.top, r.top),
appearance: Object.fromEntries(['width', 'height', 'padding', 'margin', 'color', 'backgroundColor', 'border', 'borderRadius'].map(key => [key, style[key]])),
icon: { width: icon.width, height: icon.height, color: icon.color },
gap: r.left - label.right, centerOffset: r.top + r.height / 2 - label.top - label.height / 2,
afterLabel: r.left >= label.right - 1, nested: !!button.closest('label, summary, [role=tab]') || !!button.parentElement.closest('button') }; })()`)
assert(geometry.sameLine && geometry.afterLabel, 'Help not beside label: ' + JSON.stringify(geometry))
assert(!geometry.nested, 'Help nested in another control: ' + geometry.label)
const focused = () => js('document.activeElement.tagName + ":" + document.activeElement.id + ":" + document.activeElement.getAttribute("aria-label")')
const beforeHover = await focused()
await mouse(selector)
await wait('!!document.querySelector("[role=tooltip]")')
assert.equal(await focused(), beforeHover, 'Consumer hover stole focus')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await js(`document.querySelector(${JSON.stringify(selector)}).focus()`)
await wait('!!document.querySelector("[role=tooltip]")')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await mouse(selector, true)
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`), 'Help click focused another input')
const panel = await js(`(() => { const e = document.querySelector('[role=tooltip]'), r = e.getBoundingClientRect();
const style = getComputedStyle(e);
const hit = document.elementFromPoint(r.x + r.width/2, r.y + Math.min(r.height/2, 20));
return { text: e.textContent, width: r.width, height: r.height, scrollHeight: e.scrollHeight, clientHeight: e.clientHeight,
appearance: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'padding', 'border', 'borderRadius'].map(key => [key, style[key]])),
inBounds: r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15,
wraps: e.scrollWidth <= e.clientWidth, hit: e.contains(hit), modal: !!e.closest('[aria-modal=true]') }; })()`)
assert(panel.text.trim().length > 0 && panel.inBounds && panel.wraps && panel.hit, 'Unreadable help: ' + JSON.stringify(panel))
if (root.includes('dialog') || root.includes('settings-panel') || root.includes('application-center')) assert(panel.modal)
if (describedControl) await description(describedControl)
assert.equal(await snapshot(root), before, 'Help click changed values, tabs or disclosure state')
if (artifacts) await writeFile(join(artifacts, `${capturePrefix}-${++captureIndex}.png`), (await win.webContents.capturePage()).toPNG())
await key('Escape')
assert(await js(`!document.querySelector('[role=tooltip]') && !!document.querySelector(${JSON.stringify(root)})`), 'Escape closed consumer')
assert.equal(await snapshot(root), before, 'Help dismissal changed consumer state')
if (describedControl) await description(describedControl)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await settle()
return { ...geometry, panelAppearance: panel.appearance, characters: panel.text.length, width: panel.width, height: panel.height }
}
const profileId = '00000000-0000-4000-8000-000000000001'
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1280, 800], [640, 480]]) {
capturePrefix = `${theme}-${width}x${height}`
captureIndex = 0
win.setContentSize(width, height)
await settle()
const checked = []
await mouse('#applications', true)
await wait('!!document.querySelector("#application-center")')
await mouse('[aria-label="魔法笔记 应用设置"]', true)
await wait('!!document.querySelector("#magic-note-canvas-page-count")')
assert.equal(await js('document.querySelector("#magic-note-canvas-page-count").value'), '3')
checked.push(await checkHelp('.inline-help[aria-label="常驻左侧菜单"]', '#application-center', '[aria-describedby="application-pin-help"][role=switch]'))
checked.push(await checkHelp('.inline-help[aria-label="发送画布页数"]', '#application-center', '#magic-note-canvas-page-count'))
await key('Escape')
await wait('!document.querySelector("#application-center")')
await mouse('#settings', true)
const context = '#model-context-window-' + profileId
await wait(`!!document.querySelector('${context}')`)
assert.equal(await js(`document.querySelector('${context}').value`), '128')
checked.push(await checkHelp('.inline-help[aria-label="上下文上限(可选)"]', '.settings-panel', context))
checked.push(await checkHelp('.inline-help[aria-label="自定义请求体"]', '.settings-panel', '#model-request-body-' + profileId))
for (const applicationHelp of checked.slice(0, 2)) {
assert.deepEqual(applicationHelp.appearance, checked[2].appearance, 'Application/settings help button styles differ')
assert.deepEqual(applicationHelp.icon, checked[2].icon, 'Application/settings help icons differ')
assert.deepEqual(applicationHelp.panelAppearance, checked[2].panelAppearance, 'Application/settings tooltip styles differ')
assert.equal(applicationHelp.gap, checked[2].gap, 'Application/settings label gaps differ')
assert.equal(applicationHelp.centerOffset, checked[2].centerOffset, 'Application/settings label alignment differs')
}
await key('Escape')
await wait('!document.querySelector(".settings-panel")')
await mouse('#retrieval', true)
await wait('!!document.querySelector(".knowledge-retrieval-workbench")')
const queryHelp = '.knowledge-workbench-section__heading .inline-help'
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
const disclosureHeight = await js('document.querySelector(".knowledge-workbench-settings").getBoundingClientRect().height')
await js('document.querySelector(".knowledge-workbench-settings > summary").focus({preventScroll: true})')
await key('Space')
assert(await js('document.querySelector(".knowledge-workbench-settings").open'))
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
assert.equal(await js('document.querySelector(".knowledge-retrieval-workbench textarea").value'), 'Preserve this query')
await key('Escape')
await wait('!document.querySelector(".knowledge-retrieval-workbench")')
await mouse('#activity', true)
await wait('!!document.querySelector(".page-header .inline-help")')
checked.push(await checkHelp('.page-header .inline-help', 'main'))
await mouse('#home', true)
evidence.push({ theme, viewport: `${width}x${height}`, retrievalDisclosureHeight: disclosureHeight, checked })
}
}
assert.equal(await js('document.documentElement.dataset.fixtureActions ?? "[]"'), '[]', 'Help triggered a consumer operation')
console.log('InlineHelp consumer evidence: ' + JSON.stringify(evidence))
console.log('InlineHelp consumer actions: 0; model calls: 0; Chromium AX descriptions passed closed/open/closed')
if (artifacts) await writeFile(join(artifacts, 'evidence.json'), JSON.stringify({ evidence, consumerActions: 0, modelCalls: 0 }, null, 2))
win.destroy()
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { InlineHelp } from '../../src/renderer/src/InlineHelp'
import { PageHeader } from '../../src/renderer/src/WorkspacePrimitives'
import { SettingsSectionHeader } from '../../src/renderer/src/SettingsPrimitives'
import { activateModalFocus, trapTabFocus } from '../../src/renderer/src/dialog-focus'
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
import { KnowledgeRetrievalWorkbench } from '../../src/renderer/src/KnowledgeRetrievalWorkbench'
import { ActivityPanel } from '../../src/renderer/src/ActivityPanel'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { defaultRuntimeSettings } from '../../src/shared/contracts'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
import { defaultKnowledgeRetrievalSettings } from '../../src/shared/knowledge-contracts'
import '../../src/renderer/src/styles.css'
const actions: string[] = []
const record = (action: string): void => {
actions.push(action)
document.documentElement.dataset.fixtureActions = JSON.stringify(actions)
}
const applicationSettings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false, magicNoteCanvasPageCount: 3,
})
const profileId = '00000000-0000-4000-8000-000000000001'
Object.defineProperty(window, 'goodbuddy', { value: {
settings: {
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
id: profileId, name: 'Help regression model', modelName: 'fixture-model', baseUrl: 'https://example.invalid',
protocol: 'anthropic-messages', authentication: 'none', imageGenerationQuality: 'auto',
apiKeyConfigured: false, credentialSource: 'none', contextWindowTokens: 128000, maximumOutputTokens: 16000,
requestHeaders: { 'x-fixture': 'preserve-me' }, requestBody: { temperature: 0.4 },
}], defaultModelProfileId: profileId, embeddingConnections: [],
opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, deepseekHarnessModelSource: { kind: 'platform' } }),
detectAgentRuntimes: async () => ({}),
updateRuntime: async () => { record('runtime-save'); throw new Error('Unexpected save') },
testModelConnection: async () => { record('model-call'); throw new Error('Model calls forbidden in help regression') },
},
} })
function Modal({ close }: { close: () => void }): React.JSX.Element {
const first = useRef<HTMLButtonElement>(null)
useEffect(() => activateModalFocus(() => first.current), [])
useEffect(() => {
const escape = (event: KeyboardEvent): void => { if (event.key === 'Escape') close() }
document.addEventListener('keydown', escape, true)
return () => document.removeEventListener('keydown', escape, true)
}, [close])
return createPortal(<div className="settings-backdrop">
<section role="dialog" aria-modal="true" aria-label="Help fixture" style={{ width: 'calc(100% - 32px)', maxWidth: 700,
maxHeight: 'calc(100% - 32px)', overflow: 'auto', padding: 16, background: 'var(--surface-raised)' }}
onKeyDown={event => trapTabFocus(event, event.currentTarget)}>
<button id="outside" ref={first}>Outside</button>
<PageHeader headingId="page" title="Page" help="Page help" />
<SettingsSectionHeader headingId="section" title="Section" description="Description" help="Section help" />
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<InlineHelp label="Long help" id="long-help">{'Long unbroken text: ' + 'abcdefghij'.repeat(150)}</InlineHelp>
</div>
</section>
</div>, document.body)
}
function Fixture(): React.JSX.Element {
const [view, setView] = useState('')
const [settings, setSettings] = useState(applicationSettings)
return <div className="app-shell"><div>
<button id="open" onClick={() => setView('isolated')}>Open modal</button>
<button id="applications" onClick={() => setView('applications')}>Applications</button>
<button id="settings" onClick={() => setView('settings')}>Settings</button>
<button id="retrieval" onClick={() => setView('retrieval')}>Retrieval</button>
<button id="activity" onClick={() => setView('activity')}>Activity</button>
<button id="home" onClick={() => setView('')}>Home</button>
</div>
{view === 'isolated' && <Modal close={() => setView('')} />}
{view === 'applications' && <ApplicationCenter settings={settings} pending={false} onClose={() => setView('')}
onOpen={() => record('application-open')} onRetry={() => record('application-retry')}
onUpdate={async patch => { record('application-update'); setSettings(current => ({ ...current, ...patch })); return true }} />}
{view === 'settings' && <SettingsPanel open initialCategory="model" projects={[]} onClose={() => setView('')}
onSaved={() => record('settings-saved')} onClearLocalData={async () => record('clear-data')}
onUpdateProject={async () => { throw new Error('Unexpected project update') }} />}
{view === 'retrieval' && <KnowledgeRetrievalWorkbench libraryName="Help regression library" initialQuery="Preserve this query"
settings={defaultKnowledgeRetrievalSettings} graphAvailable onClose={() => setView('')}
onTest={() => record('retrieval-test')} onSaveDefaults={() => record('retrieval-save')}
onOpenSource={() => record('source-open')} onViewContext={() => record('context-open')} />}
{view === 'activity' && <main style={{ width: '100%', minWidth: 0, padding: 16 }}><ActivityPanel records={[]}
tokenUsage={{ records: [], totals: { callCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } }}
onClear={() => record('activity-clear')} onOpenConversation={() => record('conversation-open')} /></main>}
</div>
}
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN"><Fixture /></UiLocaleProvider>)
@@ -0,0 +1,73 @@
import { createRoot } from 'react-dom/client'
import { useState } from 'react'
import { ApplicationSettingsView } from '../../src/renderer/src/ApplicationCenter'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contracts'
export async function liveNotesAnalysisRegression() {
const api = window.goodbuddy.magicNotes
const initialSettings = await window.goodbuddy.updates.getSettings()
if (initialSettings.magicNoteCanvasPageCount !== 1) throw new Error('Expected default 1 page')
const markers = ['KITE-41', 'FERN-72', 'MOON-36', 'WAVE-85', 'PINE-29', 'STAR-63', 'LIME-57', 'BIRD-94', 'WOLF-18']
const content: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas', pages: [], assets: [] }
for (const [index, marker] of markers.entries()) {
const canvas = document.createElement('canvas')
canvas.width = 700
canvas.height = 700
const context = canvas.getContext('2d')!
context.fillStyle = '#ffffff'
context.fillRect(0, 0, 700, 700)
context.fillStyle = '#172554'
context.font = 'bold 64px sans-serif'
context.fillText(`PAGE ${index + 1}`, 70, 180)
context.fillText(marker, 70, 310)
context.font = '32px sans-serif'
context.fillText('Visual inventory card', 70, 410)
content.assets.push({ id: `asset-${index + 1}`, name: `card-${index + 1}.png`, mimeType: 'image/png', dataUrl: canvas.toDataURL('image/png') })
content.pages.push({ id: `page-${index + 1}`, width: 794, height: 1123,
background: { type: 'template', template: 'blank' },
objects: [{ type: 'Image', canvasKind: 'image', assetId: `asset-${index + 1}`, originX: 'left', originY: 'top', left: 40, top: 40, width: 700, height: 700 }] })
}
const note = await api.create({ title: 'Isolated visual inventory' })
const created = await api.createEntry({ noteId: note.id, content })
const entry = async () => (await api.get(note.id)).entries.find(item => item.id === created.createdEntryId)!
const host = document.createElement('div')
document.body.append(host)
const errors: string[] = []
function Probe() {
const [settings, setSettings] = useState(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)
}} /></>
}
createRoot(host).render(<Probe />)
const wait = async (predicate: () => unknown | Promise<unknown>) => {
for (let i = 0; i < 4000; i++) {
if (errors.length) throw new Error(errors.join('\n'))
if (await predicate()) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error('Live UI wait timed out')
}
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
const runs = []
const pageCounts = new URLSearchParams(location.search).get('pages') === '8' ? [8] : [1, 8]
for (const pageCount of pageCounts) {
if (pageCount === 8) {
const select = host.querySelector<HTMLSelectElement>('select[aria-describedby="magic-note-canvas-page-count-help"]')!
select.value = '8'
select.dispatchEvent(new Event('change', { bubbles: true }))
await wait(async () => (await window.goodbuddy.updates.getSettings()).magicNoteCanvasPageCount === 8)
}
const revision = (await entry()).revision
const button = () => [...host.querySelectorAll<HTMLButtonElement>('button')].find(node => ['Analyze with AI', 'Analyze again'].includes(node.textContent?.trim() ?? ''))
await wait(() => button() && !button()!.disabled)
button()!.click()
await wait(async () => (await entry()).revision > revision && (await entry()).comments.length)
runs.push({ pageCount, comments: (await entry()).comments })
}
return { errors, markers, runs, fixturePageCount: content.pages.length }
}
@@ -0,0 +1,85 @@
import { safeStorage } from 'electron'
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
// Read an encrypted snapshot so even corrupt-settings recovery cannot touch user data.
export async function prepareLiveNotesAnalysis(directory: string) {
const source = process.env.GB_NOTES_LIVE_SETTINGS!
const original = readFileSync(source)
const snapshot = join(directory, 'runtime-settings.snapshot.json')
writeFileSync(snapshot, original)
const store = new RuntimeSettingsStore(snapshot, {
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: () => { throw new Error('Live probe must not encrypt credentials') },
decrypt: value => safeStorage.decryptString(value)
}, {})
const settings = await store.getResolvedSettings().finally(() => unlinkSync(snapshot))
writeFileSync(join(directory, 'preflight.json'), JSON.stringify({
realCallCount: 0, model: settings.modelName, protocol: settings.modelProtocol,
supportsImageInput: settings.supportsImageInput,
authentication: settings.modelAuthentication,
credentialReadable: Boolean(settings.apiKey),
encryptionAvailable: safeStorage.isEncryptionAvailable(),
defaultModelProfileId: settings.defaultModelProfileId,
profiles: settings.modelProfiles.map(profile => ({ id: profile.id, model: profile.modelName,
supportsImageInput: profile.supportsImageInput, credentialReadable: Boolean(profile.apiKey) }))
}, null, 2))
if (!settings.supportsImageInput || !settings.modelName ||
(settings.modelAuthentication !== 'none' && !settings.apiKey)) {
throw new Error('Persisted default model lacks vision support or readable credentials')
}
settings.workspacePath = directory
const requests: { imageCount: number; images: string[]; text: string; toolCount: number; status?: number }[] = []
const save = () => writeFileSync(join(directory, 'live-evidence.json'), JSON.stringify({
model: settings.modelName, protocol: settings.modelProtocol,
realCallCount: requests.length,
originalSettingsUnchanged: readFileSync(source).equals(original), requests
}, null, 2))
const originalFetch = globalThis.fetch
const pageCounts = process.env.GB_NOTES_LIVE_PAGES === '8' ? [8] : [1, 8]
globalThis.fetch = async (input, init) => {
if (requests.length >= pageCounts.length) throw new Error('Live probe request budget exhausted')
const body = JSON.parse(String(init?.body))
const images: string[] = []
const texts: string[] = []
const visit = (value: unknown) => {
if (!value || typeof value !== 'object') return
const part = value as Record<string, unknown>
let data: string | undefined
if (part.type === 'input_image' && typeof part.image_url === 'string') data = part.image_url
if (part.type === 'image_url') data = (part.image_url as { url?: string })?.url
if (part.type === 'image' && (part.source as { type?: string })?.type === 'base64') {
const source = part.source as { media_type: string; data: string }
data = `data:${source.media_type};base64,${source.data}`
}
if (data) images.push(data)
if (typeof part.text === 'string') texts.push(part.text)
if (typeof part.content === 'string') texts.push(part.content)
for (const child of Object.values(part)) {
if (Array.isArray(child)) child.forEach(visit)
else if (child && typeof child === 'object') visit(child)
}
}
visit(body.input ?? body.messages)
const expected = pageCounts[requests.length]
if (images.length !== expected || (body.tools?.length ?? 0) !== 0) {
throw new Error(`Unexpected outbound payload: ${images.length} images, ${body.tools?.length ?? 0} tools`)
}
const observation = { imageCount: images.length, images: images.map((data, index) => {
const bytes = Buffer.from(data.slice(data.indexOf(',') + 1), 'base64')
const name = `request-${requests.length + 1}-page-${index + 1}.png`
writeFileSync(join(directory, name), bytes)
return `${name} sha256:${createHash('sha256').update(bytes).digest('hex')}`
}), text: texts.join('\n'), toolCount: body.tools?.length ?? 0, status: undefined as number | undefined }
requests.push(observation)
save()
const response = await originalFetch(input, init)
observation.status = response.status
save()
return response
}
save()
return { settings, save }
}
+23 -6
View File
@@ -1,14 +1,23 @@
import { app, BrowserWindow } from 'electron'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { dirname, 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'
import { prepareLiveNotesAnalysis } from './magic-notes-analysis-live'
const directory = process.env.GB_NOTES_PROBE_DIRECTORY!
const url = process.env.GB_NOTES_PROBE_URL!
const preload = join(directory, 'preload.cjs')
app.setPath('userData', join(directory, 'profile'))
if (process.env.GB_NOTES_LIVE === '1' && process.platform === 'win32') {
const localState = JSON.parse(readFileSync(join(dirname(process.env.GB_NOTES_LIVE_SETTINGS!), 'Local State'), 'utf8'))
mkdirSync(join(directory, 'profile'), { recursive: true })
writeFileSync(join(directory, 'profile', 'Local State'), JSON.stringify({ os_crypt: localState.os_crypt }))
}
app.on('will-quit', () => {
if (process.env.GB_NOTES_LIVE === '1') rmSync(join(directory, 'profile', 'Local State'), { force: true })
})
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1400, height: 1000, webPreferences: {
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload
@@ -16,16 +25,20 @@ app.whenReady().then(async () => {
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const supportsImageInput = process.env.GB_NOTES_PROBE_VISION === 'true'
const runtimeSettings = { workspacePath: directory, supportsImageInput,
const live = process.env.GB_NOTES_LIVE === '1' ? await prepareLiveNotesAnalysis(directory) : undefined
const runtimeSettings = live?.settings ?? { 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' })
await applicationSettings.update({ magicNoteCommentMode: live ? 'after-save-manual' : 'after-save-auto', magicNoteCommentFormat: 'structured' })
const dispose = registerIpcHandlers(win, { capability: 'text' } as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => runtimeSettings } as never,
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => live ? {
supportsImageInput: true, defaultModelProfileId: runtimeSettings.defaultModelProfileId,
modelProfiles: runtimeSettings.modelProfiles.map(profile => ({ id: profile.id, supportsImageInput: profile.supportsImageInput }))
} : runtimeSettings } as never,
{} as never, { clear() {}, cancelImport() {} } as never, {} as never, database,
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined,
applicationSettings)
win.webContents.on('console-message', event => console.log(event.message))
if (!live) win.webContents.on('console-message', event => console.log(event.message))
try {
await win.loadURL(url)
for (let i = 0; i < 400; i++) {
@@ -34,6 +47,10 @@ app.whenReady().then(async () => {
}
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, requests: (globalThis as typeof globalThis & { analysisRequests: unknown[] }).analysisRequests }))
if (live) {
writeFileSync(join(directory, 'ui.png'), (await win.webContents.capturePage()).toPNG())
live.save()
}
} finally {
await dispose()
database.close()
@@ -8,9 +8,11 @@ import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contra
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
import { liveNotesAnalysisRegression } from './magic-notes-analysis-live-regression'
async function notesAnalysisRegression() {
await i18n.changeLanguage('en-US')
if (new URLSearchParams(location.search).get('live') === '1') return liveNotesAnalysisRegression()
const api = window.goodbuddy.magicNotes
const initialSettings = await window.goodbuddy.updates.getSettings()
if (initialSettings.magicNoteCanvasPageCount !== 1) throw new Error('Expected default page count 1')