Let users retain useful assistant replies or complete conversations without leaving their chat workflow. Preview and edit captured text, choose an existing note or atomically create a note with its first entry, and return to the source conversation or message from saved records. Add an application-scoped Notes workbar tab with title/body search and quick text appends. Preserve drafts across conversation switches and guard closing, replacement and disabling. Schema 48 stores optional source metadata without rewriting file-backed bodies; source deletion retains the saved note. Older clients cannot reopen an upgraded database without a pre-upgrade backup. Release note: 可将助手回答或完整会话预览、编辑后加入魔法笔记,并从笔记返回原会话或消息。右侧快速笔记支持搜索和追加文字,切换会话保留草稿;采集本身不调用模型,保存后自动评论仍遵循已有设置。 Validation: isolated HEAD-based candidate passed 22 files / 930 focused tests, plus 20 gateway tests after including its create-result fixture adjustment. Real Electron App/preload/IPC/SQLite acceptance passed for all 502 historical messages, exact-entry/source navigation, native keyboard input in a 214px panel, reopen and source-deletion retention, with zero capture model calls. Repository lint and web TypeScript passed. Cached whitespace and secret-pattern checks passed; native-client changes and unrelated IPC extraction are excluded. Validation limits: the isolated source tree reuses installed node_modules, where a parallel task upgraded DSH from the committed 0.1.2-rc.1 to 0.1.7-rc.2. Full typecheck reports 14 DSH errors in three unchanged files; the full-suite attempt with --bail=1 also fails in deepseek-harness-host.test.ts. These are not all-green repository or release-package results. Earlier execution-time full suite failures and fixture repairs remain recorded in the feature progress doc.
184 lines
12 KiB
TypeScript
184 lines
12 KiB
TypeScript
import { app, BrowserWindow } from 'electron'
|
|
import assert from 'node:assert/strict'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { registerIpcHandlers } from '../../src/main/ipc'
|
|
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
|
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
|
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
|
|
|
const directory = process.env.GB_CAPTURE_DIRECTORY!
|
|
app.setPath('userData', join(directory, 'profile'))
|
|
|
|
app.whenReady().then(async () => {
|
|
const win = new BrowserWindow({ show: false, width: 1280, height: 900, webPreferences: {
|
|
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false,
|
|
preload: join(directory, 'preload.cjs')
|
|
} })
|
|
const path = join(directory, 'assistant.sqlite')
|
|
const database = new AssistantDatabase(path)
|
|
database.initialize(directory)
|
|
const project = database.listProjects()[0]!
|
|
const header = { id: randomUUID(), title: 'Capture discussion', projectId: project.id, updatedAt: Date.now() }
|
|
const messages = Array.from({ length: 502 }, (_, index) => ({ id: randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const,
|
|
content: `CAPTURE_HISTORY_${index}_END`, state: 'complete' as const, createdAt: 1775000000000 + index }))
|
|
database.saveLocalConversations([{ header, messages }])
|
|
const existing = database.createMagicNote({ title: 'Collected answers' })
|
|
database.createMagicNoteEntry({ noteId: existing.id, content: { version: 1, ops: [{ insert: 'Keep existing entry\n' }] }, plainText: 'Keep existing entry' })
|
|
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
|
await applicationSettings.update({ magicNotesEnabled: true, magicNoteCommentMode: 'after-save-manual', checkUpdatesOnStartup: false })
|
|
const settings = { ...defaultRuntimeSettings, workspacePath: directory, modelProfiles: [], embeddingConnections: [] }
|
|
// Unrelated App services are test environment dependencies. All capture-related handlers are production.
|
|
const dispose = registerIpcHandlers(win, { capability: 'text', getStatus: async () => ({ available: true, name: 'Capture test', capability: 'text' }) } as never,
|
|
'CommandOrControl+Shift+Space', { getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
|
|
{} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
|
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
|
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never, database,
|
|
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
|
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
|
{ getPending: async () => undefined } as never)
|
|
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
|
|
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
|
async function wait(script: string, label: string): Promise<void> {
|
|
for (let i = 0; i < 400; i++) {
|
|
if (await run(script)) return
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
}
|
|
throw new Error(`Timed out: ${label}`)
|
|
}
|
|
function button(name: string, root = 'document'): string {
|
|
return `[...${root}.querySelectorAll('button,[role="menuitem"]')].find(e => (e.getAttribute('aria-label') || e.textContent.trim()) === ${JSON.stringify(name)} && e.getClientRects().length)`
|
|
}
|
|
async function click(name: string, root?: string): Promise<void> {
|
|
const expression = button(name, root)
|
|
await wait(`Boolean(${expression})`, name)
|
|
await run(`${expression}.click()`)
|
|
}
|
|
async function key(keyCode: string): Promise<void> {
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
|
|
if (keyCode === 'Enter') win.webContents.sendInputEvent({ type: 'char', keyCode: '\r' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
|
|
await new Promise(resolve => setTimeout(resolve, 80))
|
|
}
|
|
const panel = 'document.querySelector(".magic-note-panel")'
|
|
const evidence: Record<string, unknown> = {}
|
|
let closed = false
|
|
try {
|
|
await win.loadURL(process.env.GB_CAPTURE_URL!)
|
|
await wait('document.body.innerText.includes("CAPTURE_HISTORY_501_END")', 'App persisted history')
|
|
assert.equal(await run('document.body.innerText.includes("CAPTURE_HISTORY_0_END")'), false, 'old history must initially be folded')
|
|
// The last assistant reply is rendered by the real ChatTimeline.
|
|
await run(`[...document.querySelectorAll('button')].filter(e => e.getAttribute('aria-label') === 'Add to note' || e.title === 'Add to note').at(-1).click()`)
|
|
await wait('Boolean(document.querySelector("#compact-note-text"))', 'capture preview')
|
|
assert.equal(await run('(document.querySelector("#compact-note-text")).value'), messages[501]!.content)
|
|
assert.equal(await run(`${button('Add to note', panel)}.disabled`), true)
|
|
await wait(`${panel}.innerText.includes('Collected answers')`, 'existing target')
|
|
await run(`${panel}.querySelector('.magic-note-panel__note').click()`)
|
|
await click('Add to note', panel)
|
|
await wait(`${panel}.innerText.includes('Keep existing entry') && !document.querySelector('#compact-note-text')`, 'saved existing entry')
|
|
const saved = database.getMagicNote(existing.id)
|
|
assert.equal(saved.entries.length, 2)
|
|
const captured = saved.entries.find(entry => entry.source)!
|
|
assert.equal(captured.plainText, messages[501]!.content)
|
|
assert.deepEqual(captured.content, { version: 1, ops: [{ insert: `${messages[501]!.content}\n` }] })
|
|
assert.equal(captured.source?.kind, 'message')
|
|
assert.equal(captured.source?.conversationId, header.id)
|
|
assert.equal(captured.source?.projectId, project.id)
|
|
assert.deepEqual(captured.source?.messageIds, [messages[501]!.id])
|
|
assert.equal(captured.source?.conversationTitle, header.title)
|
|
assert.equal(captured.source?.projectName, project.name)
|
|
evidence.existingSaved = true
|
|
await click('Open in full workspace', `document.getElementById('compact-note-entry-${captured.id}')`)
|
|
await wait(`Boolean(document.getElementById('magic-note-entry-${captured.id}'))`, 'full workspace exact entry')
|
|
evidence.workspaceOpened = true
|
|
await click('View source message', `document.getElementById('magic-note-entry-${captured.id}')`)
|
|
await wait('document.activeElement?.textContent?.includes("CAPTURE_HISTORY_501_END")', 'source message focus')
|
|
evidence.sourceOpened = true
|
|
await click('Conversation actions')
|
|
await click('Save conversation to note')
|
|
await wait('Boolean(document.querySelector("#compact-note-text"))', 'full history preview')
|
|
const expected = messages.map(message => `## ${message.role === 'user' ? 'User' : 'Assistant'}\n\n${message.content}`).join('\n\n')
|
|
assert.equal(await run('document.querySelector("#compact-note-text").value'), expected)
|
|
evidence.fullHistoryCount = messages.length
|
|
await click('New note', panel)
|
|
await wait('document.activeElement?.id === "compact-note-title"', 'new title focus')
|
|
assert.equal(await run('document.querySelector("#compact-note-title").value'), header.title)
|
|
await click('Add to note', panel)
|
|
await wait(`!document.querySelector('#compact-note-text') && ${panel}.innerText.includes('CAPTURE_HISTORY_0_END')`, 'saved full history')
|
|
const newNote = database.listMagicNotes().find(note => note.id !== existing.id)!
|
|
let detail = database.getMagicNote(newNote.id)
|
|
assert.equal(detail.entries.length, 1)
|
|
assert.equal(detail.title, header.title)
|
|
assert.equal(detail.entries[0]!.plainText, expected)
|
|
assert.deepEqual(detail.entries[0]!.content, { version: 1, ops: [{ insert: `${expected}\n` }] })
|
|
assert.equal(detail.entries[0]!.source?.kind, 'conversation')
|
|
assert.equal(detail.entries[0]!.source?.conversationId, header.id)
|
|
assert.equal(detail.entries[0]!.source?.projectName, project.name)
|
|
assert.deepEqual(detail.entries[0]!.source?.messageIds, messages.map(message => message.id))
|
|
evidence.newSaved = true
|
|
// Electron resize and the production sidebar transition settle asynchronously.
|
|
win.setSize(760, 800)
|
|
await wait(`${panel}.getBoundingClientRect().width > 100`, 'narrow panel layout after resize')
|
|
await run(`document.querySelector('#compact-note-append').focus()`)
|
|
await key('Escape')
|
|
await wait('document.activeElement?.id === "compact-note-search"', 'Escape returns to list and search')
|
|
await key('Tab')
|
|
assert.equal(await run('document.activeElement?.classList.contains("magic-note-panel__note")'), true)
|
|
await key('Enter')
|
|
await wait('Boolean(document.querySelector("#compact-note-append"))', 'keyboard opens note')
|
|
const bounds = await run<{ width: number; overflow: number }>(`(() => { const p=${panel};return {width:p.getBoundingClientRect().width,overflow:p.scrollWidth-p.clientWidth} })()`)
|
|
assert.ok(bounds.width > 0 && bounds.width <= 480, JSON.stringify(bounds))
|
|
assert.ok(bounds.overflow <= 1, JSON.stringify(bounds))
|
|
await run(`document.querySelector('#compact-note-append').focus()`)
|
|
await win.webContents.insertText('Keyboard append')
|
|
await wait(`${button('Add to note', panel)}.disabled === false`, 'keyboard draft ready')
|
|
await key('Tab')
|
|
await key('Tab')
|
|
assert.equal(await run('document.activeElement?.textContent.trim()'), 'Add to note')
|
|
await key('Enter')
|
|
await wait(`${panel}.innerText.includes('Keyboard append') && document.querySelector('#compact-note-append').value === ''`, 'keyboard save')
|
|
detail = database.getMagicNote(newNote.id)
|
|
assert.equal(detail.entries.length, 2)
|
|
assert.ok(detail.entries.some(entry => entry.plainText === 'Keyboard append' && !entry.source))
|
|
evidence.keyboard = true
|
|
evidence.narrowPanel = true
|
|
evidence.panelWidth = bounds.width
|
|
await run('window.goodbuddy.magicNotes.get(' + JSON.stringify(newNote.id) + ')')
|
|
await dispose()
|
|
database.close()
|
|
closed = true
|
|
const reopened = new AssistantDatabase(path)
|
|
reopened.initialize(directory)
|
|
try {
|
|
assert.deepEqual(reopened.getMagicNote(newNote.id), detail)
|
|
assert.deepEqual(reopened.getMagicNote(existing.id), saved)
|
|
reopened.deleteLocalConversation(header.id)
|
|
assert.deepEqual(reopened.getMagicNote(newNote.id), detail)
|
|
evidence.sqliteReopened = true
|
|
evidence.sourceSurvivesDeletion = true
|
|
} finally { reopened.close() }
|
|
const sql = new DatabaseSync(path)
|
|
try {
|
|
const rows = sql.prepare('SELECT source_json FROM magic_note_entries WHERE source_json IS NOT NULL').all()
|
|
assert.equal(rows.length, 2)
|
|
assert.ok(rows.some(row => JSON.parse(String(row.source_json)).messageIds.length === 502))
|
|
evidence.sqliteSourceRows = rows.length
|
|
} finally { sql.close() }
|
|
evidence.modelAttempts = (globalThis as typeof globalThis & { captureModelAttempts?: number }).captureModelAttempts ?? 0
|
|
} catch (error) {
|
|
evidence.error = error instanceof Error ? error.stack : String(error)
|
|
evidence.layout = await run(`(() => { let e=${panel}; const rows=[]; while(e) {rows.push({tag:e.tagName,classes:e.className,width:e.getBoundingClientRect().width,style:e.getAttribute('style')});e=e.parentElement} return rows })()`)
|
|
evidence.body = await run('document.body.innerText')
|
|
if (!closed) {
|
|
await dispose()
|
|
database.close()
|
|
}
|
|
} finally {
|
|
writeFileSync(join(directory, 'result.json'), JSON.stringify(evidence))
|
|
win.destroy()
|
|
}
|
|
app.exit(0)
|
|
}).catch(error => { console.error(error); app.exit(1) })
|