fix: reduce redundant work during chat and note startup
Avoid hidden activity renders, no-op message saves and attachment scans when pinning conversations. Reuse note body inspection and skip unchanged attachment reference writes while preserving recovery and cleanup behavior. Skip unnecessary Markdown normalization and memoize subagent lookups without delaying live rich text. Add storage operation counts and real Electron rendering equivalence benchmarks; full-render speedup remains unproven. Validation: 5397 tests passed, 84 skipped; typecheck passed. Lint passed excluding two existing untracked sidebar probe files. Release note: Reduce repeated rendering and disk work during chat, conversation pinning and note startup while preserving existing content and interactions.
This commit is contained in:
@@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独
|
||||
会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。
|
||||
清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。
|
||||
|
||||
引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录
|
||||
转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件
|
||||
发生变化时仍按原事务更新,临时资产仍须转存。
|
||||
|
||||
助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。
|
||||
知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。
|
||||
知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-09-28: Startup Body Read Reuse
|
||||
|
||||
Startup repair now reuses the asset references collected during revision
|
||||
inspection, as described in [file reconciliation](./technical-design.md#schema-38-migration).
|
||||
The persisted format and revision recovery rules are unchanged.
|
||||
|
||||
- Real temporary SQLite/filesystem fixture: 120 entries across 120 notes,
|
||||
half rich text and half canvas, with 4,039,560 bytes of body JSON.
|
||||
Five initializations each read 240 bodies before and 120 after the change.
|
||||
Logical bytes read fell from 8,079,120 to 4,039,560 per initialization.
|
||||
- Baseline durations were 148.23, 134.42, 131.06, 140.66 and 172.90 ms;
|
||||
after durations were 136.89, 119.11, 116.41, 115.14 and 116.26 ms.
|
||||
Medians were 140.66 and 116.41 ms. These Windows runs used warm filesystem
|
||||
caches on a shared machine; they do not measure cold application startup.
|
||||
- `npx vitest run src/main/magic-notes/magic-note-storage.test.ts src/main/assistant/assistant-database.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant-storage-startup.test.ts tests/assistant-storage-worker.test.ts`
|
||||
passed 154 tests. Coverage includes failed media inspection, missing/invalid
|
||||
bodies, retained shared assets, revision mismatch recovery and migration.
|
||||
- Full `npm test`: 5382 passed, 84 skipped, 3 failed. The failures were the
|
||||
App question-round request wait and Electron note capture focus/layout waits.
|
||||
All three passed in targeted reruns; the full run was not all green.
|
||||
The capture rerun exercised production App/preload/IPC/SQLite and reopening.
|
||||
- `npm run typecheck` passed. Full lint still reports 10 errors in the existing
|
||||
untracked `sidebar-final-probe.ts` and `sidebar-final-run.mjs`; lint excluding
|
||||
those two files passed. No provider calls were made for these storage benchmarks.
|
||||
|
||||
## 2026-09-27: Shared Destructive Button Styles
|
||||
|
||||
The draft discard Portal now receives the global `danger-solid` styles. The
|
||||
|
||||
@@ -173,7 +173,12 @@ empty content. Failed creates leave no accepted database membership; their
|
||||
unaccepted files are removed on the next access or reopen.
|
||||
|
||||
After commit, reconciliation derives `note.json` from database membership and
|
||||
scans all retained entry files before removing unreferenced assets. Manifest or
|
||||
inspects all retained entry files before removing unreferenced assets. Startup
|
||||
repair reads each current body once to inspect its revision and collect asset
|
||||
references, then passes the complete per-note reference set to reconciliation.
|
||||
If media inspection fails, cleanup uses its existing deferred retry path rather
|
||||
than trusting a partial set. Revision mismatch recovery still hydrates the body;
|
||||
legacy conversion still verifies its written content. Manifest or
|
||||
GC failures log `console.warn` with the note ID and error and remain in an
|
||||
in-memory retry set. Later database access retries; reopening derives cleanup
|
||||
again from membership. These cleanup failures do not turn a committed save,
|
||||
|
||||
@@ -280,6 +280,44 @@
|
||||
Renderer 堆内存。
|
||||
- **验收:** 测试数据、运行步骤和原始结果可重复执行。
|
||||
|
||||
#### 2026-09-28 Measurement Evidence (Partial)
|
||||
|
||||
`npx vitest run tests/streaming-markdown.electron.test.ts` runs the real
|
||||
MarkdownRenderer in Electron 43.2.0 / Chromium 150.0.7871.129. It compares the
|
||||
original normalizer with fast paths that skip unchanged text, retaining live
|
||||
Markdown, math, code and HTML behavior. It also measures a 102,400-byte answer
|
||||
at six growing prefixes, alternating original/current execution order in the
|
||||
same page, with one warm-up sweep and three measured sweeps (18 samples each).
|
||||
|
||||
| Measurement | Original | Current |
|
||||
| --- | --- | --- |
|
||||
| 100KiB growing-prefix render median, isolated run | 54.80 ms | 54.65 ms |
|
||||
| Same run p95 | 95.50 ms | 106.10 ms |
|
||||
| Growing-prefix render median, full-suite run | 53.50 ms | 53.10 ms |
|
||||
| Same run p95 | 93.70 ms | 83.40 ms |
|
||||
|
||||
These development-mode measurements include synchronous React rendering, DOM
|
||||
commit and style/layout, but exclude frame waiting and paint. With 18 samples,
|
||||
nearest-rank p95 is the maximum sample. The results do not establish an overall
|
||||
rendering improvement. Smaller 6.4-7.2K-character corpora do show lower normalizer
|
||||
cost: prose median 33-34 microseconds to below 1 microsecond, and mixed math/code
|
||||
median 32-35 to 16-20 microseconds. The production changes remain limited to this
|
||||
preprocessing and memoizing unchanged subagent-derived collections. In the
|
||||
four-subagent regression, initial render plus six updates builds the lookup map
|
||||
once instead of seven times; block-dependent scans still run when blocks change.
|
||||
|
||||
Validation: 550 prefix normalization comparisons, 1,100 short-prefix DOM
|
||||
comparisons and 24 long-prefix DOM comparisons passed. The real Electron
|
||||
terminal-status probe covers cancellation, failure, retry and persistence via
|
||||
three loopback model-stub requests; the Mermaid viewer covers nine scenarios
|
||||
and ten PNG downloads. No external model calls were used for these probes.
|
||||
Full `npm test` passed 459 files / 5,397 tests, with 11 files / 84 tests skipped.
|
||||
Type checking and scoped lint passed. Full lint remains blocked by the existing
|
||||
untracked sidebar-final probe files.
|
||||
|
||||
This is partial evidence for PERF-03, not completion: full-App input latency,
|
||||
frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
|
||||
|
||||
### PERF-04 流式更新合并
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
|
||||
@@ -1888,7 +1888,7 @@ export class AssistantDatabase {
|
||||
return JSON.stringify({ storage: 'file', version: content.version, revision })
|
||||
}
|
||||
|
||||
private reconcileMagicNoteFiles(noteId?: string): void {
|
||||
private reconcileMagicNoteFiles(noteId?: string, inspectedAssets?: ReadonlySet<string>): void {
|
||||
const database = this.database!
|
||||
// SQL and authoritative bodies are already committed. Cleanup must never
|
||||
// turn an accepted save/delete/reset into a retryable UI write failure.
|
||||
@@ -1901,7 +1901,7 @@ export class AssistantDatabase {
|
||||
else this.noteStorage.clear()
|
||||
} else if (database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(noteId)) {
|
||||
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(noteId) as Array<{ id: string }>
|
||||
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id))
|
||||
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id), inspectedAssets)
|
||||
} else {
|
||||
this.noteStorage.deleteNote(noteId)
|
||||
}
|
||||
@@ -1929,6 +1929,7 @@ export class AssistantDatabase {
|
||||
let processed = 0
|
||||
let migrated = false
|
||||
for (const note of notes) {
|
||||
let referencedAssets: Set<string> | undefined = new Set<string>()
|
||||
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(note.id) as Array<{ id: string }>
|
||||
for (const { id } of entries) {
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
@@ -1937,6 +1938,7 @@ export class AssistantDatabase {
|
||||
// One payload at a time: a canvas may contain a large PDF.
|
||||
const row = database.prepare('SELECT * FROM magic_note_entries WHERE id = ?').get(id) as MagicNoteEntryRow
|
||||
const pointer = JSON.parse(row.content_json) as { storage?: string; revision?: number }
|
||||
const inspected = pointer.storage === 'file' ? this.noteStorage.inspect(note.id, id) : undefined
|
||||
if (pointer.storage !== 'file') {
|
||||
const content = JSON.parse(row.content_json) as MagicNoteContent
|
||||
this.noteStorage.write({ noteId: note.id, id, content, revision: row.revision, updatedAt: row.updated_at })
|
||||
@@ -1948,7 +1950,7 @@ export class AssistantDatabase {
|
||||
magicNotePlainText(content), magicNoteEmbeddedBytes(content), id
|
||||
)
|
||||
migrated = true
|
||||
} else if (this.noteStorage.revision(note.id, id) !== pointer.revision) {
|
||||
} else if (inspected!.revision !== pointer.revision) {
|
||||
const saved = this.noteStorage.read(note.id, id)
|
||||
database.prepare(`UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ?,
|
||||
revision = MAX(revision, ?), updated_at = ?, comments_json = '[]', analyzed_at = NULL WHERE id = ?`).run(
|
||||
@@ -1958,6 +1960,9 @@ export class AssistantDatabase {
|
||||
database.prepare('UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?').run(saved.updatedAt, note.id)
|
||||
this.syncMagicNoteTodos(database, note.id, id, saved.content, saved.updatedAt)
|
||||
}
|
||||
const assets = (inspected ?? this.noteStorage.inspect(note.id, id)).referencedAssets
|
||||
if (!assets) referencedAssets = undefined
|
||||
else if (referencedAssets) for (const asset of assets) referencedAssets.add(asset)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -1966,7 +1971,7 @@ export class AssistantDatabase {
|
||||
processed++
|
||||
if (migrated) onProgress?.({ stage: 'converting', processed, total, bytesBefore })
|
||||
}
|
||||
this.reconcileMagicNoteFiles(note.id)
|
||||
this.reconcileMagicNoteFiles(note.id, referencedAssets)
|
||||
}
|
||||
if (!noteIds) this.reconcileMagicNoteFiles()
|
||||
this.dirtyMagicNotes.clear()
|
||||
|
||||
@@ -2,13 +2,147 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DatabaseSync, StatementSync } from 'node:sqlite'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
|
||||
|
||||
describe('conversation attachment files and references', () => {
|
||||
it.each([false, true])('measures repeated references with an attachment: %s', async (populated) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
try {
|
||||
const conversation = randomUUID(), id = randomUUID()
|
||||
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
|
||||
if (populated) storage.save(context, JSON.stringify(context), Buffer.from('source'))
|
||||
const ids = populated ? [id] : []
|
||||
storage.reference(conversation, 'draft', conversation, ids)
|
||||
const get = vi.spyOn(StatementSync.prototype, 'get')
|
||||
const all = vi.spyOn(StatementSync.prototype, 'all')
|
||||
const run = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
let counts: { selects: number; writes: number; transactions: number }
|
||||
try {
|
||||
for (let index = 0; index < 100; index++) storage.reference(conversation, 'draft', conversation, ids)
|
||||
counts = {
|
||||
selects: get.mock.calls.length + all.mock.calls.length,
|
||||
writes: run.mock.calls.length,
|
||||
transactions: exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE').length
|
||||
}
|
||||
} finally { get.mockRestore(); all.mockRestore(); run.mockRestore(); exec.mockRestore() }
|
||||
const durations: number[] = []
|
||||
for (let sample = 0; sample < 3; sample++) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < 1000; index++) storage.reference(conversation, 'draft', conversation, ids)
|
||||
durations.push(Number((performance.now() - start).toFixed(3)))
|
||||
}
|
||||
console.info(JSON.stringify({ populated, countedCalls: 100, ...counts, callsPerSample: 1000, durationsMs: durations }))
|
||||
expect(counts.writes).toBe(0)
|
||||
expect(counts.transactions).toBe(0)
|
||||
expect(storage.draft(conversation)).toEqual(populated ? [context] : [])
|
||||
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it.each(['draft', 'message', 'queue', 'parsing'] as const)('preserves ordered replacement, ownership and cleanup for %s references', async (kind) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
|
||||
try {
|
||||
const conversation = randomUUID(), otherConversation = randomUUID(), owner = conversation
|
||||
const ids = [randomUUID(), randomUUID(), randomUUID()] as const
|
||||
for (const id of ids) {
|
||||
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
|
||||
storage.save(context, JSON.stringify(context), Buffer.from(id))
|
||||
}
|
||||
const refs = () => database.prepare('SELECT attachment_id, conversation_id FROM attachment_refs WHERE kind = ? AND owner_id = ? ORDER BY rowid').all(kind, owner)
|
||||
const expected = (ordered: string[], conversationId = conversation) => ordered.map((id) => ({ attachment_id: id, conversation_id: conversationId }))
|
||||
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1]])
|
||||
expect(refs()).toEqual(expected(ids.slice(0, 2)))
|
||||
expect(storage.original(ids[0]).path).toContain(join('conversation-assets', conversation, 'documents', ids[0]))
|
||||
await expect(readFile(join(root, 'temp', 'document-parsing', ids[0], 'original.txt'))).rejects.toThrow()
|
||||
const get = vi.spyOn(StatementSync.prototype, 'get')
|
||||
const run = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
try {
|
||||
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1], 'missing'])
|
||||
expect(get.mock.calls.map(([id]) => id)).toEqual([ids[0], 'missing', ids[1]])
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
} finally { get.mockRestore(); run.mockRestore(); exec.mockRestore() }
|
||||
|
||||
storage.reference(conversation, kind, owner, [ids[1], ids[0]])
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]]))
|
||||
if (kind === 'draft') expect(storage.draft(owner).map(({ id }) => id)).toEqual([ids[1], ids[0]])
|
||||
storage.reference(otherConversation, kind, owner, [ids[1], ids[0]])
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
|
||||
storage.deleteConversation(conversation, ids)
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
|
||||
|
||||
storage.reference(otherConversation, 'message', 'shared-owner', [ids[0]])
|
||||
storage.reference(otherConversation, kind, owner, [ids[2]])
|
||||
expect(refs()).toEqual(expected([ids[2]], otherConversation))
|
||||
storage.collect()
|
||||
expect(storage.has(ids[0])).toBe(true)
|
||||
expect(storage.has(ids[1])).toBe(false)
|
||||
expect(storage.original(ids[2]).data).toEqual(Buffer.from(ids[2]))
|
||||
storage.reference(otherConversation, kind, owner, ['missing'])
|
||||
expect(refs()).toEqual([])
|
||||
const emptyRun = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const emptyExec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
try {
|
||||
storage.reference(otherConversation, kind, owner, [])
|
||||
storage.reference(otherConversation, kind, owner, ['missing', 'missing'])
|
||||
expect(emptyRun).not.toHaveBeenCalled()
|
||||
expect(emptyExec).not.toHaveBeenCalled()
|
||||
} finally { emptyRun.mockRestore(); emptyExec.mockRestore() }
|
||||
storage.collect()
|
||||
expect(storage.has(ids[2])).toBe(false)
|
||||
storage.deleteConversation(otherConversation)
|
||||
expect(storage.has(ids[0])).toBe(false)
|
||||
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('promotes temporary files even when references already match and rolls back failed replacements', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
|
||||
try {
|
||||
const conversation = randomUUID(), first = randomUUID(), second = randomUUID()
|
||||
for (const id of [first, second]) {
|
||||
const context = { id, name: 'image.png', kind: 'image' as const, size: 6, preview: 'source' }
|
||||
storage.save(context, JSON.stringify(context), Buffer.from(id))
|
||||
}
|
||||
database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)').run(first, conversation, 'draft', conversation)
|
||||
database.exec(`CREATE TRIGGER reject_promotion BEFORE UPDATE OF path ON attachments
|
||||
BEGIN SELECT RAISE(ABORT, 'promotion failed'); END`)
|
||||
expect(() => storage.reference(conversation, 'draft', conversation, [first])).toThrow('promotion failed')
|
||||
expect(storage.original(first).path).toContain(join('temp', 'document-parsing', first))
|
||||
expect(storage.original(first).data).toEqual(Buffer.from(first))
|
||||
database.exec('DROP TRIGGER reject_promotion')
|
||||
storage.reference(conversation, 'draft', conversation, [first, first])
|
||||
expect(storage.original(first).path).toContain(join('conversation-assets', conversation, 'images', first))
|
||||
expect(storage.original(first).data).toEqual(Buffer.from(first))
|
||||
await expect(readFile(join(root, 'temp', 'document-parsing', first, 'original.png'))).rejects.toThrow()
|
||||
|
||||
database.exec(`CREATE TRIGGER reject_reference BEFORE INSERT ON attachment_refs
|
||||
WHEN NEW.attachment_id = '${second}' BEGIN SELECT RAISE(ABORT, 'reference failed'); END`)
|
||||
expect(() => storage.reference(conversation, 'draft', conversation, [first, second])).toThrow('reference failed')
|
||||
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([first])
|
||||
// Promotion precedes the reference transaction and remains durable after its rollback.
|
||||
expect(storage.original(second).path).toContain(join('conversation-assets', conversation, 'images', second))
|
||||
expect(storage.original(second).data).toEqual(Buffer.from(second))
|
||||
database.exec('DROP TRIGGER reject_reference')
|
||||
storage.reference(conversation, 'draft', conversation, [second, first])
|
||||
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([second, first])
|
||||
expect(database.prepare('PRAGMA integrity_check').get()).toMatchObject({ integrity_check: 'ok' })
|
||||
expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('retains interrupted originals without turning them into sendable draft attachments', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
|
||||
@@ -117,7 +117,17 @@ export class ConversationAttachmentStorage {
|
||||
}
|
||||
|
||||
reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void {
|
||||
const assets = ids.filter((id) => this.has(id)).map((id) => this.row(id))
|
||||
const assets: AssetRow[] = []
|
||||
for (const id of new Set(ids)) {
|
||||
const asset = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
|
||||
if (asset) assets.push(asset)
|
||||
}
|
||||
const refs = this.database.prepare(`SELECT attachment_id, conversation_id FROM attachment_refs
|
||||
WHERE kind = ? AND owner_id = ? ORDER BY rowid`).all(kind, ownerId) as { attachment_id: string; conversation_id: string }[]
|
||||
// Draft order is visible, and matching references can still point to temporary files.
|
||||
if (refs.length === assets.length && assets.every((asset, index) =>
|
||||
refs[index]?.attachment_id === asset.id && refs[index]?.conversation_id === conversationId && !asset.path.startsWith('temp')
|
||||
)) return
|
||||
for (const asset of assets) {
|
||||
if (!asset.path.startsWith(`temp`)) continue
|
||||
const context = JSON.parse(asset.metadata) as ContextAttachment
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
import { ImageGenerationService } from './agent/image-generation-service'
|
||||
import { ApplicationSettingsStore } from './application-settings-store'
|
||||
import * as desktopNotification from './desktop-notification'
|
||||
@@ -4521,6 +4523,95 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('pins and unpins without attachment reconciliation or import checks', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-'))
|
||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const assets = new ConversationAttachmentStorage(root, results)
|
||||
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
|
||||
try {
|
||||
database.initialize(root)
|
||||
const conversationId = '00000000-0000-4000-8000-000000000301'
|
||||
const messageId = '00000000-0000-4000-8000-000000000302'
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, title: 'Pin regression', updatedAt: 1 },
|
||||
messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }]
|
||||
}])
|
||||
const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original'))
|
||||
assets.reference(conversationId, 'draft', conversationId, [assetId])
|
||||
assets.reference(conversationId, 'message', messageId, [assetId])
|
||||
const before = database.getConversation(conversationId)
|
||||
const reconcile = vi.spyOn(assets, 'reconcile')
|
||||
const collect = vi.spyOn(assets, 'collect')
|
||||
const hasOwner = vi.spyOn(database, 'hasAttachmentOwner')
|
||||
const contextManager = {
|
||||
assets,
|
||||
activeContextIds: vi.fn(() => []),
|
||||
cancelUnavailableImport: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
const webContents = {
|
||||
on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents, isDestroyed: vi.fn(() => false),
|
||||
on: vi.fn(), removeListener: vi.fn()
|
||||
}
|
||||
dispose = registerIpcHandlers(
|
||||
window as never, { capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
||||
contextManager as never, {} as never, database,
|
||||
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
|
||||
)
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)!
|
||||
expect(pin).toBeTypeOf('function')
|
||||
webContents.send.mockClear()
|
||||
for (const pinned of [true, true, false]) {
|
||||
expect(pin(event, { conversationId, pinned })).toBeUndefined()
|
||||
expect(database.getConversation(conversationId)).toEqual({ ...before, pinned })
|
||||
}
|
||||
expect(webContents.send.mock.calls).toEqual([
|
||||
[ipcChannels.conversationsChanged],
|
||||
[ipcChannels.conversationsChanged],
|
||||
[ipcChannels.conversationsChanged]
|
||||
])
|
||||
expect({
|
||||
reconciliations: reconcile.mock.calls.length,
|
||||
ownerChecks: hasOwner.mock.calls.length,
|
||||
collections: collect.mock.calls.length,
|
||||
activeContextReads: contextManager.activeContextIds.mock.calls.length,
|
||||
importChecks: contextManager.cancelUnavailableImport.mock.calls.length
|
||||
}).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 })
|
||||
expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId])
|
||||
expect(assets.get(assetId).parsingState).toBe('parsing')
|
||||
expect(assets.original(assetId).data).toEqual(Buffer.from('original'))
|
||||
|
||||
webContents.send.mockClear()
|
||||
expect(() => pin(event, { conversationId, pinned: 'yes' })).toThrow()
|
||||
expect(() => pin({ ...event, sender: {} }, { conversationId, pinned: true })).toThrow()
|
||||
expect(() => pin(event, {
|
||||
conversationId: '00000000-0000-4000-8000-000000000399', pinned: true
|
||||
})).toThrow()
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
expect(database.getConversation(conversationId)).toEqual(before)
|
||||
|
||||
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
||||
expect(collect).toHaveBeenCalledOnce()
|
||||
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
||||
expect(assets.has(assetId)).toBe(false)
|
||||
} finally {
|
||||
await dispose?.()
|
||||
assets.close()
|
||||
await results.close()
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000301'
|
||||
|
||||
@@ -6397,8 +6397,6 @@ export function registerIpcHandlers(
|
||||
assistantDatabase.setConversationPinned(
|
||||
conversationSetPinnedSchema.parse(input)
|
||||
)
|
||||
contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds())
|
||||
if (contextManager.assets) contextManager.cancelUnavailableImport()
|
||||
publishConversationChange()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,12 @@ import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magi
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
const readFileSync = vi.fn(actual.readFileSync)
|
||||
return { ...actual, readFileSync, default: { ...actual, readFileSync } }
|
||||
})
|
||||
|
||||
const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync }
|
||||
|
||||
const directories: string[] = []
|
||||
@@ -16,6 +22,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const connection of connections.splice(0)) connection.close()
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
|
||||
vi.mocked(readFileSync).mockClear()
|
||||
})
|
||||
|
||||
function setup(options: ConstructorParameters<typeof AssistantDatabase>[1] = {}) {
|
||||
@@ -53,6 +60,36 @@ function canvas(size = 40): MagicNoteCanvasContent {
|
||||
}
|
||||
|
||||
describe('Magic note SQLite and filesystem storage', () => {
|
||||
it('measures current entry startup body reads on real SQLite and filesystem fixtures', () => {
|
||||
const { database, directory, sql } = setup()
|
||||
const paths: string[] = []
|
||||
for (let index = 0; index < 120; index++) {
|
||||
const content = index % 2 ? canvas() : structuredClone(rich)
|
||||
if (content.version === 1) content.ops.unshift({ insert: 'x'.repeat(32 * 1024) })
|
||||
else content.pages[0]!.objects.push({ type: 'IText', text: 'x'.repeat(32 * 1024) })
|
||||
const note = database.createMagicNote({ title: `Synthetic ${index}`, content })
|
||||
paths.push(join(directory, 'notes', note.id, 'entries', `${note.entries[0]!.id}.json`))
|
||||
}
|
||||
database.close()
|
||||
const bodyPaths = new Set(paths)
|
||||
const reads = vi.mocked(readFileSync)
|
||||
const durations: number[] = []
|
||||
const counts: number[] = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
reads.mockClear()
|
||||
const start = performance.now()
|
||||
database.initialize(directory)
|
||||
durations.push(Number((performance.now() - start).toFixed(2)))
|
||||
counts.push(reads.mock.calls.filter(([path]) => bodyPaths.has(String(path))).length)
|
||||
for (const path of paths) expect(reads.mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
|
||||
expect(reads.mock.calls.some(([path]) => String(path).includes(join(directory, 'notes')) && String(path).includes('assets'))).toBe(false)
|
||||
database.close()
|
||||
}
|
||||
console.info('Synthetic note startup', { entries: paths.length, bodyBytes: paths.reduce((sum, path) => sum + statSync(path).size, 0), counts, durationsMs: durations })
|
||||
expect(counts).toEqual(Array(5).fill(paths.length))
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
})
|
||||
|
||||
it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => {
|
||||
const { database, sql, path, directory } = setup()
|
||||
const created = database.createMagicNote({ title: 'Existing note', content: rich })
|
||||
@@ -77,6 +114,43 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
expect(database.getMagicNote(created.id)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => {
|
||||
const { database, directory } = setup()
|
||||
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
|
||||
const entries = database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' }).entries
|
||||
const root = join(directory, 'notes', note.id)
|
||||
const bodyPath = join(root, 'entries', `${entries[1]!.id}.json`)
|
||||
const original = readFileSync(bodyPath, 'utf8')
|
||||
const manifest = readFileSync(join(root, 'note.json'), 'utf8')
|
||||
writeFileSync(join(root, 'assets', 'unused.bin'), 'keep until inspection succeeds')
|
||||
const assets = readdirSync(join(root, 'assets')).map((name) => [name, readFileSync(join(root, 'assets', name))] as const)
|
||||
database.close()
|
||||
if (failure === 'missing') rmSync(bodyPath)
|
||||
else if (failure === 'invalid-json') writeFileSync(bodyPath, '{')
|
||||
else {
|
||||
const body = JSON.parse(original)
|
||||
body.content.pages = null
|
||||
writeFileSync(bodyPath, JSON.stringify(body))
|
||||
}
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
if (failure === 'invalid-media') {
|
||||
database.initialize(directory)
|
||||
expect(warning).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id }))
|
||||
} else expect(() => database.initialize(directory)).toThrow()
|
||||
for (const [name, bytes] of assets) expect(readFileSync(join(root, 'assets', name))).toEqual(bytes)
|
||||
expect(readFileSync(join(root, 'note.json'), 'utf8')).toBe(manifest)
|
||||
database.close()
|
||||
writeFileSync(bodyPath, original)
|
||||
vi.mocked(readFileSync).mockClear()
|
||||
database.initialize(directory)
|
||||
for (const entry of entries) {
|
||||
const path = join(root, 'entries', `${entry.id}.json`)
|
||||
expect(vi.mocked(readFileSync).mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
|
||||
}
|
||||
expect(database.getMagicNote(note.id).entries).toEqual(entries)
|
||||
expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => {
|
||||
const { database, sql, path, directory } = setup()
|
||||
const text = 'data:text/plain;base64,aGVsbG8'
|
||||
@@ -339,14 +413,20 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
const { database, sql, directory } = setup()
|
||||
const note = database.createMagicNote({ title: 'Repair', content: rich })
|
||||
const entry = note.entries[0]!
|
||||
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision,
|
||||
comments: [{ id: randomUUID(), kind: 'summary', content: 'Stale analysis' }] })
|
||||
const analyzed = database.getMagicNoteEntry(entry.id)
|
||||
sql.exec(`CREATE TRIGGER fail_note_update BEFORE UPDATE OF content_json ON magic_note_entries
|
||||
BEGIN SELECT RAISE(ABORT, 'index unavailable'); END`)
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
|
||||
expect(() => database.searchMagicNotes('PDF text', 10)).toThrow('index unavailable')
|
||||
sql.exec('DROP TRIGGER fail_note_update')
|
||||
if (reopen) { database.close(); database.initialize(directory) }
|
||||
expect(database.searchMagicNotes('PDF text', 10)).toHaveLength(1)
|
||||
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: 1 })
|
||||
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: analyzed.revision + 1, comments: [], analyzedAt: undefined })
|
||||
const assetNames = readdirSync(join(directory, 'notes', note.id, 'assets'))
|
||||
expect(assetNames).toHaveLength(2)
|
||||
expect(assetNames.some((name) => name.endsWith('.pdf'))).toBe(true)
|
||||
expect(database.listMagicTodos()).toEqual([expect.objectContaining({ title: 'Canvas task' })])
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow('已被更新')
|
||||
})
|
||||
|
||||
@@ -75,8 +75,19 @@ export class MagicNoteStorage {
|
||||
return join(this.rootPath, noteId, 'entries', `${entryId}.json`)
|
||||
}
|
||||
|
||||
revision(noteId: string, entryId: string): number {
|
||||
return (JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry).revision
|
||||
inspect(noteId: string, entryId: string): { revision: number; referencedAssets?: Set<string> } {
|
||||
const entry = JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry
|
||||
const referencedAssets = new Set<string>()
|
||||
try {
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referencedAssets.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
} catch {
|
||||
// Let reconciliation retry failed media inspection through its deferred-cleanup path.
|
||||
return { revision: entry.revision }
|
||||
}
|
||||
return { revision: entry.revision, referencedAssets }
|
||||
}
|
||||
|
||||
write(entry: MagicNoteStoredEntry): void {
|
||||
@@ -108,18 +119,21 @@ export class MagicNoteStorage {
|
||||
return entry
|
||||
}
|
||||
|
||||
reconcile(noteId: string, entryIds: string[]): void {
|
||||
reconcile(noteId: string, entryIds: string[], inspectedAssets?: ReadonlySet<string>): void {
|
||||
const notePath = join(this.rootPath, noteId)
|
||||
const entriesPath = join(notePath, 'entries')
|
||||
const retained = new Set(entryIds.map((id) => `${id}.json`))
|
||||
const referenced = new Set<string>()
|
||||
// Read every retained body before deleting anything, including shared assets.
|
||||
for (const name of retained) {
|
||||
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
// Repair can supply references only after inspecting every retained body.
|
||||
// Otherwise read them all before deleting anything, including shared assets.
|
||||
if (!inspectedAssets) {
|
||||
for (const name of retained) {
|
||||
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
this.atomicWrite(join(notePath, 'note.json'), JSON.stringify({ id: noteId, entries: entryIds }))
|
||||
if (existsSync(entriesPath)) {
|
||||
@@ -133,7 +147,7 @@ export class MagicNoteStorage {
|
||||
const assetsPath = join(notePath, 'assets')
|
||||
if (existsSync(assetsPath)) {
|
||||
for (const name of readdirSync(assetsPath)) {
|
||||
if (!referenced.has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
|
||||
if (!(inspectedAssets ?? referenced).has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ import { ActivityPanel } from './ActivityPanel'
|
||||
import { upsertActivityRecord, type ActivityRecord } from './activity-store'
|
||||
import i18n from './i18n'
|
||||
|
||||
const activityRenderProbe = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./WorkspacePrimitives', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./WorkspacePrimitives')>()
|
||||
return {
|
||||
...original,
|
||||
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
|
||||
if (props.headingId === 'activity-panel-title') activityRenderProbe()
|
||||
return <original.PageHeader {...props} />
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeRecord(
|
||||
index: number,
|
||||
status: ActivityRecord['status'] = 'completed'
|
||||
@@ -77,6 +89,37 @@ function makeTokenUsage(): TokenUsageSummary {
|
||||
}
|
||||
|
||||
describe('ActivityPanel', () => {
|
||||
it('skips unchanged hidden renders while retaining selection and accepting real updates', () => {
|
||||
const record = makeRecord(1, 'running')
|
||||
const props = { records: [record], tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() }
|
||||
const { rerender } = render(<div hidden={false}><ActivityPanel {...props} /></div>)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
|
||||
const node = screen.getByRole('button', { name: /活动 1,工具/u })
|
||||
fireEvent.click(node)
|
||||
const detail = screen.getByLabelText('选中的活动节点详情')
|
||||
activityRenderProbe.mockClear()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
rerender(<div hidden data-update={index}><ActivityPanel {...props} /></div>)
|
||||
}
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(0)
|
||||
const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() }
|
||||
rerender(<div hidden><ActivityPanel {...updated} /></div>)
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
|
||||
rerender(<div hidden={false}><ActivityPanel {...updated} /></div>)
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail)
|
||||
expect(node).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(node).toHaveClass('activity-track__node--failed')
|
||||
expect(within(detail).getByText('Real update')).toBeInTheDocument()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: '打开所属对话' }))
|
||||
expect(updated.onOpenConversation).toHaveBeenCalledWith(record.conversationId)
|
||||
expect(props.onOpenConversation).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空 1 条活动记录' }))
|
||||
expect(updated.onClear).toHaveBeenCalledOnce()
|
||||
expect(props.onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens timeline guidance separately from tab navigation', () => {
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
@@ -239,7 +239,7 @@ function groupActivityRecordsByProject(
|
||||
})
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
export const ActivityPanel = memo(function ActivityPanel({
|
||||
projects = [],
|
||||
records,
|
||||
tokenUsage,
|
||||
@@ -1165,4 +1165,4 @@ export function ActivityPanel({
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,6 +49,18 @@ const speechRecognitionMocks = vi.hoisted(() => ({
|
||||
|
||||
const messageRenderProbe = vi.hoisted(() => vi.fn());
|
||||
const assistantTasksProbe = vi.hoisted(() => vi.fn());
|
||||
const activityRenderProbe = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
|
||||
return {
|
||||
...original,
|
||||
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
|
||||
if (props.headingId === "activity-panel-title") activityRenderProbe();
|
||||
return <original.PageHeader {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./use-unviewed-completions", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./use-unviewed-completions")>();
|
||||
@@ -2982,6 +2994,46 @@ describe("App", () => {
|
||||
expect(route).not.toHaveAttribute("hidden");
|
||||
});
|
||||
|
||||
it("isolates hidden Activity renders from composer and streaming updates", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Activity render isolation" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
const heading = await screen.findByRole("heading", { name: "运行记录" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "活动时间线" }));
|
||||
const node = document.querySelector<HTMLButtonElement>(".activity-track__node")!;
|
||||
fireEvent.click(node);
|
||||
const detail = screen.getByLabelText("选中的活动节点详情");
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
const route = heading.closest('[data-route="activity"]')!;
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
activityRenderProbe.mockClear();
|
||||
for (const value of ["a", "ab", "abc"]) {
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value } });
|
||||
}
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
activityRenderProbe.mockClear();
|
||||
for (const delta of ["Stream", " response", " updated"]) {
|
||||
act(() => agentListener?.({ requestId, type: "text", delta }));
|
||||
await screen.findByText(new RegExp(delta.trim(), "u"));
|
||||
}
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
act(() => agentListener?.({ requestId, type: "done" }));
|
||||
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
|
||||
expect(screen.getByRole("tab", { name: "活动时间线" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("选中的活动节点详情")).toBe(detail);
|
||||
expect(node).toHaveAttribute("aria-pressed", "true");
|
||||
expect(document.querySelectorAll(".activity-track__node")).toHaveLength(2);
|
||||
fireEvent.click(within(detail).getByRole("button", { name: "打开所属对话" }));
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("abc");
|
||||
});
|
||||
|
||||
it("keeps Settings outside the workspace KeepAlive cache", async () => {
|
||||
let now = 2_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => ++now);
|
||||
@@ -12238,6 +12290,28 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Persist question resolution" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => agentListener?.({ requestId, type: "question", questionId: "persisted-question",
|
||||
questions: [{ header: "Input", question: "Resolve me?", options: [], multiple: false, custom: true }] }));
|
||||
expect(screen.getByText("Resolve me?")).toBeInTheDocument();
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
|
||||
expect(screen.queryByText("Resolve me?")).not.toBeInTheDocument();
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(api.conversations.saveLocal).mock.calls[0]![0].flatMap(item => item.messages)).toHaveLength(1);
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
|
||||
const view = render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
|
||||
+24
-20
@@ -3876,19 +3876,21 @@ function App(): React.JSX.Element {
|
||||
messageId: string,
|
||||
update: (message: Message) => Message,
|
||||
): void => {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === conversationId
|
||||
? {
|
||||
...conversation,
|
||||
updatedAt: Date.now(),
|
||||
messages: conversation.messages.map((message) =>
|
||||
message.id === messageId ? update(message) : message,
|
||||
),
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
setConversations((current) => {
|
||||
const conversationIndex = current.findIndex(item => item.id === conversationId);
|
||||
const conversation = current[conversationIndex];
|
||||
if (!conversation) return current;
|
||||
const messageIndex = conversation.messages.findIndex(message => message.id === messageId);
|
||||
const message = conversation.messages[messageIndex];
|
||||
if (!message) return current;
|
||||
const updated = update(message);
|
||||
if (updated === message) return current;
|
||||
const messages = [...conversation.messages];
|
||||
messages[messageIndex] = updated;
|
||||
const next = [...current];
|
||||
next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -8159,7 +8161,13 @@ function App(): React.JSX.Element {
|
||||
return 'opened';
|
||||
};
|
||||
|
||||
const openActivityConversation = (conversationId: string): void => {
|
||||
const clearActivity = useCallback((): void => {
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false;
|
||||
setLegacyActivityHistoryMayBeIncomplete(false);
|
||||
setActivityRecords([]);
|
||||
}, []);
|
||||
|
||||
const openActivityConversation = useCallback((conversationId: string): void => {
|
||||
const open = async (): Promise<void> => {
|
||||
let conversation = conversationsRef.current.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
@@ -8195,7 +8203,7 @@ function App(): React.JSX.Element {
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
requestWorkspaceLeave("chat", () => { void open(); });
|
||||
};
|
||||
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]);
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
if (!task.conversationId) {
|
||||
@@ -11361,11 +11369,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
<ActivityPanel
|
||||
onClear={() => {
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false;
|
||||
setLegacyActivityHistoryMayBeIncomplete(false);
|
||||
setActivityRecords([]);
|
||||
}}
|
||||
onClear={clearActivity}
|
||||
onOpenConversation={openActivityConversation}
|
||||
projects={projects}
|
||||
records={activityRecords}
|
||||
|
||||
@@ -861,6 +861,97 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses subagent derivations during streaming and invalidates only changed dependencies', () => {
|
||||
const props = {
|
||||
...callbacks, artifactById: new Map(), conversationId: 'subagent-stream',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1
|
||||
}
|
||||
const subagents: NonNullable<Message['subagents']> = ['a', 'b', 'c', 'd'].map(id => ({
|
||||
childTaskId: id, expertId: id, expertName: `Expert ${id}`,
|
||||
routingMode: 'native', state: 'running'
|
||||
}))
|
||||
const makeBlocks = (content: string): NonNullable<Message['blocks']> => [
|
||||
{ id: 'b', type: 'subagent', childTaskId: 'b' },
|
||||
{ id: 'missing', type: 'subagent', childTaskId: 'missing' },
|
||||
{ id: 'text', type: 'text', content },
|
||||
{ id: 'a', type: 'subagent', childTaskId: 'a' }
|
||||
]
|
||||
let message: Message = {
|
||||
id: 'stream', role: 'assistant', content: 'Initial', createdAt: 0,
|
||||
state: 'streaming', subagents, blocks: makeBlocks('Initial')
|
||||
}
|
||||
const map = vi.spyOn(subagents, 'map')
|
||||
const filter = vi.spyOn(subagents, 'filter')
|
||||
const blockFilters = [vi.spyOn(message.blocks!, 'filter')]
|
||||
// Question IDs also filter blocks; count only passes returning subagent markers.
|
||||
const counts = () => ({
|
||||
map: map.mock.calls.length,
|
||||
ordered: blockFilters.reduce((count, probe) => count + probe.mock.results.filter(
|
||||
result => result.type === 'return' && result.value[0]?.type === 'subagent'
|
||||
).length, 0),
|
||||
unordered: filter.mock.calls.length
|
||||
})
|
||||
try {
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const cardNames = () => Array.from(view.container.querySelectorAll(
|
||||
'.subagent-status-card__heading strong'
|
||||
), element => element.textContent)
|
||||
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
|
||||
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
message = { ...message, status: `Streaming ${index}` }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getByRole('status', { name: `Streaming ${index}` })).toBeVisible()
|
||||
}
|
||||
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const content = `Streamed block ${index}`
|
||||
message = { ...message, content, blocks: makeBlocks(content) }
|
||||
blockFilters.push(vi.spyOn(message.blocks!, 'filter'))
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getByText(content)).toBeVisible()
|
||||
expect(markdownRenderProbe).toHaveBeenLastCalledWith(content)
|
||||
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
|
||||
}
|
||||
expect(counts()).toEqual({ map: 1, ordered: 4, unordered: 4 })
|
||||
|
||||
// Both anchored lookup and unanchored fallback must receive new child data.
|
||||
message = { ...message, subagents: subagents.map(child => ({
|
||||
...child, expertName: `Updated ${child.childTaskId}`, state: 'completed',
|
||||
output: `Result ${child.childTaskId}`
|
||||
})) }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d'])
|
||||
for (const card of view.container.querySelectorAll<HTMLDetailsElement>('.subagent-status-card')) {
|
||||
expect(card).toHaveClass('subagent-status-card--completed')
|
||||
fireEvent.click(card.querySelector('summary')!)
|
||||
fireEvent(card, new Event('toggle'))
|
||||
const id = card.querySelector('strong')!.textContent!.slice(-1)
|
||||
expect(within(card).getByText(`Result ${id}`)).toBeVisible()
|
||||
}
|
||||
|
||||
// Move a fallback child into the ordered blocks without duplicating it.
|
||||
message = { ...message, blocks: [{ id: 'd', type: 'subagent', childTaskId: 'd' }] }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c'])
|
||||
for (const blocks of [undefined, []]) {
|
||||
message = { ...message, blocks }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d'])
|
||||
}
|
||||
message = { ...message, subagents: undefined }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual([])
|
||||
} finally {
|
||||
map.mockRestore()
|
||||
filter.mockRestore()
|
||||
blockFilters.forEach(probe => probe.mockRestore())
|
||||
}
|
||||
})
|
||||
|
||||
it('shows every parallel expert output in its own expandable card', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
||||
@@ -608,20 +608,20 @@ function ChatMessageRowView({
|
||||
const compressionMarkers =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression ? [message.contextCompression] : [])
|
||||
const subagentsById = new Map(
|
||||
const subagentsById = useMemo(() => new Map(
|
||||
(message.subagents ?? []).map((subagent) => [
|
||||
subagent.childTaskId,
|
||||
subagent
|
||||
])
|
||||
)
|
||||
const orderedSubagentIds = new Set(
|
||||
), [message.subagents])
|
||||
const orderedSubagentIds = useMemo(() => new Set(
|
||||
message.blocks
|
||||
?.filter((block) => block.type === 'subagent')
|
||||
.map((block) => block.childTaskId)
|
||||
)
|
||||
const unorderedSubagents = message.subagents?.filter(
|
||||
), [message.blocks])
|
||||
const unorderedSubagents = useMemo(() => message.subagents?.filter(
|
||||
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
|
||||
)
|
||||
), [message.subagents, orderedSubagentIds])
|
||||
const compressionLabel = (
|
||||
compression: ConversationContextCompressionMarker
|
||||
): string =>
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
InlineMarkdown,
|
||||
MarkdownRenderer
|
||||
MarkdownRenderer,
|
||||
normalizeLatexDelimiters
|
||||
} from './MarkdownRenderer'
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
@@ -31,6 +32,26 @@ vi.mock('mermaid', () => ({
|
||||
}))
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
it.each([
|
||||
['# Title\n\n**bold** `code`\n', '# Title\n\n**bold** `code`\n'],
|
||||
['plain \\', 'plain \\'],
|
||||
['partial \\(x', 'partial $x'],
|
||||
['partial x\\)', 'partial x$'],
|
||||
['\\(x\\)', '$$\nx\n$$'],
|
||||
['\\[x\\]', '$$\nx\n$$'],
|
||||
['$x$', '$$\nx\n$$'],
|
||||
['`\\(code\\)` then \\(x\\)', '`\\(code\\)` then $x$'],
|
||||
['~~~tex\n$x$\n\\(x\\)\n~~~', '~~~tex\n$x$\n\\(x\\)\n~~~']
|
||||
])('preserves normalization across fast paths: %j', (content, expected) => {
|
||||
expect(normalizeLatexDelimiters(content)).toBe(expected)
|
||||
})
|
||||
|
||||
it('preserves CRLF conversion inside code and standalone HTML without math', () => {
|
||||
expect(normalizeLatexDelimiters('```txt\r\na\r\n```')).toBe('```txt\na\n```')
|
||||
expect(normalizeLatexDelimiters('<section>\r\na\r\n</section>')).toBe('<section>\na\n</section>')
|
||||
expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
app: { onPreviewEscape: vi.fn((listener: () => void) => {
|
||||
|
||||
@@ -134,6 +134,9 @@ const wholeMarkdownFence =
|
||||
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
|
||||
|
||||
function replaceLatexDelimiters(line: string): string {
|
||||
if (!line.includes('\\(') && !line.includes('\\)')) {
|
||||
return line
|
||||
}
|
||||
let output = ''
|
||||
let index = 0
|
||||
let codeDelimiterLength = 0
|
||||
@@ -171,7 +174,11 @@ function replaceLatexDelimiters(line: string): string {
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizeLatexDelimiters(content: string): string {
|
||||
export function normalizeLatexDelimiters(content: string): string {
|
||||
// Without math markers or CRLF, every line would be returned unchanged.
|
||||
if (!/[$\\\r]/u.test(content)) {
|
||||
return content
|
||||
}
|
||||
let fence:
|
||||
| {
|
||||
character: '`' | '~'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { createServer } from 'vite'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('measures real streaming Markdown and preserves every prefix against the original normalizer', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-streaming-markdown-'))
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [{
|
||||
name: 'streaming-markdown-instrumentation', enforce: 'pre',
|
||||
async load(id) {
|
||||
if (!id.endsWith('MarkdownRenderer.tsx?streaming-original')) return
|
||||
const source = await readFile(id.split('?')[0]!, 'utf8')
|
||||
return source.replace(/(?:export )?function replaceLatexDelimiters[\s\S]*?(?=function unwrapMarkdownFence)/u,
|
||||
'import { originalNormalize as normalizeLatexDelimiters } from "/tests/support/streaming-markdown-corpus.ts"\n\n')
|
||||
},
|
||||
transform(source, id) {
|
||||
if (id.split('?')[0]!.endsWith('/MarkdownRenderer.tsx')) {
|
||||
return source + '\nexport { normalizeLatexDelimiters as benchmarkNormalize }\n'
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/streaming-markdown.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await server.transformIndexHtml('/streaming-markdown.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/support/streaming-markdown-perf.tsx"></script></body></html>'))
|
||||
})
|
||||
}
|
||||
}, react()],
|
||||
server: { host: '127.0.0.1', port: 0, watch: null }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_MARKDOWN_URL: server.resolvedUrls!.local[0] + 'streaming-markdown.html',
|
||||
GOODBUDDY_MARKDOWN_DIRECTORY: directory }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/streaming-markdown-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(), 150_000)
|
||||
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/u).filter(line => line.startsWith('Markdown ')).join('\n') + '\n')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
if (child.exitCode === null) child.kill()
|
||||
}
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
@@ -0,0 +1,101 @@
|
||||
// Frozen pre-optimization implementation: an independent streaming equivalence oracle.
|
||||
function originalReplace(line: string): string {
|
||||
let output = ''
|
||||
let index = 0
|
||||
let codeDelimiterLength = 0
|
||||
while (index < line.length) {
|
||||
if (line[index] === '`') {
|
||||
let runLength = 1
|
||||
while (line[index + runLength] === '`') runLength += 1
|
||||
if (codeDelimiterLength === 0 || codeDelimiterLength === runLength) {
|
||||
codeDelimiterLength = codeDelimiterLength === 0 ? runLength : 0
|
||||
}
|
||||
output += line.slice(index, index + runLength)
|
||||
index += runLength
|
||||
continue
|
||||
}
|
||||
if (codeDelimiterLength === 0 && line[index] === '\\' &&
|
||||
line[index - 1] !== '\\' && (line[index + 1] === '(' || line[index + 1] === ')')) {
|
||||
output += '$'
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
output += line[index]
|
||||
index += 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function originalNormalize(content: string): string {
|
||||
let fence: { character: '`' | '~'; length: number } | undefined
|
||||
return content.split(/\r?\n/u).map(line => {
|
||||
if (fence) {
|
||||
const closingFence = new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`, 'u')
|
||||
if (closingFence.test(line)) fence = undefined
|
||||
return line
|
||||
}
|
||||
const openingFence = /^ {0,3}(`{3,}|~{3,})/u.exec(line)
|
||||
if (openingFence) {
|
||||
const marker = openingFence[1]!
|
||||
fence = { character: marker[0] as '`' | '~', length: marker.length }
|
||||
return line
|
||||
}
|
||||
const standaloneDollar = /^ {0,3}\$(?!\$)(.+?)(?<!\\)\$\s*$/u.exec(line)
|
||||
if (standaloneDollar) return `$$\n${standaloneDollar[1]}\n$$`
|
||||
const standaloneParentheses = /^ {0,3}\\\(\s*(.*?)\s*\\\)\s*$/u.exec(line)
|
||||
if (standaloneParentheses) return `$$\n${standaloneParentheses[1]}\n$$`
|
||||
const singleLineDisplay = /^ {0,3}\\\[\s*(.*?)\s*\\\]\s*$/u.exec(line)
|
||||
if (singleLineDisplay) return `$$\n${singleLineDisplay[1]}\n$$`
|
||||
if (/^ {0,3}\\\[\s*$/u.test(line)) return '$$'
|
||||
if (/^ {0,3}\\\]\s*$/u.test(line)) return '$$'
|
||||
return originalReplace(line)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
export const equivalenceCorpus = [
|
||||
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
|
||||
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
|
||||
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
|
||||
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
|
||||
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
|
||||
'```markdown\r\n# Title\r\n\r\n\\(x\\)\r\n```',
|
||||
'<html>\r\n<body><p>\\(x\\)</p></body>\r\n</html>',
|
||||
'```html\r\n<section><strong>bold</strong></section>\r\n```',
|
||||
'<section>**raw**\r\ntext</section>',
|
||||
'partial \\(x + \\) then `code \\( and `` and \\[\n\\',
|
||||
'\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n'
|
||||
]
|
||||
|
||||
const prose = '# Streaming answer\n\n' + (
|
||||
'A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' +
|
||||
'- First item\n- Second item\n\n> A quoted explanation.\n\n'
|
||||
).repeat(48)
|
||||
export const performanceCorpus = [
|
||||
{ name: 'prose', content: prose },
|
||||
{ name: 'mixed', content: (prose.slice(0, 1200) + '\n\\(x^2 + y^2\\)\n\n' +
|
||||
'```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n').repeat(5) },
|
||||
{ name: 'crlf', content: prose.replaceAll('\n', '\r\n') }
|
||||
]
|
||||
|
||||
const longSection = [
|
||||
'## Deployment review\n',
|
||||
'The service processes incoming records in order and stores the last completed sequence. ' +
|
||||
'A restart resumes from that sequence so operators can inspect partial progress without repeating successful work. ' +
|
||||
'Keep **validation results** next to the source record and link the [runbook](https://example.com/runbook) in the report.\n',
|
||||
'Before deployment, compare the proposed configuration with the active settings. ' +
|
||||
'Check the database connection, confirm the worker count, and run a small representative batch. ' +
|
||||
'Record the elapsed time and any rejected records before increasing concurrency.\n',
|
||||
'- Validate required fields before writing.\n- Commit each completed batch.\n- Review rejected records separately.\n',
|
||||
'```ts\nasync function processBatch(records: Record<string, string>[]) {\n' +
|
||||
' for (const record of records) {\n const label = record.name ?? "unnamed"\n' +
|
||||
' await store.save({ ...record, label })\n }\n}\n```\n',
|
||||
'| Check | Expected result | Follow-up |\n| --- | --- | --- |\n' +
|
||||
'| Validation | Required fields present | Review rejected rows |\n' +
|
||||
'| Persistence | Completed batches stored | Resume from checkpoint |\n' +
|
||||
'| Throughput | Stable processing time | Inspect slow operations |\n',
|
||||
'> Keep the original input available until the final reconciliation has completed.\n\n'
|
||||
].join('\n')
|
||||
const longBody = longSection.repeat(Math.floor(102_400 / longSection.length))
|
||||
// ASCII makes character and UTF-8 byte lengths identical: exactly 100 KiB.
|
||||
export const longAnswerCorpus = longBody + 'Final review notes. '.repeat(100)
|
||||
.slice(0, 102_400 - longBody.length)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
app.setPath('userData', join(process.env.GOODBUDDY_MARKDOWN_DIRECTORY, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors = []
|
||||
win.webContents.on('console-message', event => {
|
||||
if (event.level === 'error') errors.push(event.message)
|
||||
})
|
||||
await win.loadURL(process.env.GOODBUDDY_MARKDOWN_URL)
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (await win.webContents.executeJavaScript('typeof window.streamingMarkdown === "function"')) break
|
||||
await setTimeout(25)
|
||||
}
|
||||
const result = await win.webContents.executeJavaScript('window.streamingMarkdown()')
|
||||
assert(result.prefixesChecked > 500)
|
||||
assert.equal(result.domChecks, result.prefixesChecked * 2)
|
||||
assert.equal(result.longAnswer.bytes, 102_400)
|
||||
assert.equal(result.longAnswer.prefixLengths.length, 6)
|
||||
assert.equal(result.longAnswer.samplesPerVariant, 18)
|
||||
assert.equal(result.longAnswer.domChecks, 24)
|
||||
assert.equal(result.longAnswer.finalDomEquivalent, true)
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('Markdown ' + JSON.stringify({ electron: process.versions.electron,
|
||||
chromium: process.versions.chrome, ...result }))
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,149 @@
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import * as current from '../../src/renderer/src/MarkdownRenderer'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import { equivalenceCorpus, longAnswerCorpus, originalNormalize, performanceCorpus } from './streaming-markdown-corpus'
|
||||
import '../../src/renderer/src/fonts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
type Instrumented = typeof current & { benchmarkNormalize: (content: string) => string }
|
||||
const normalize = (current as Instrumented).benchmarkNormalize
|
||||
const host = document.getElementById('root')!
|
||||
host.className = 'markdown-content'
|
||||
host.style.cssText = 'width: 800px; padding: 16px'
|
||||
const root = createRoot(host)
|
||||
function snapshot(container = host): string {
|
||||
const clone = container.cloneNode(true) as HTMLElement
|
||||
const ids = new Map<string, string>()
|
||||
// Separate component instances receive different React useId values.
|
||||
// Canonicalize only those IDs/references; retain all content and iframe srcdoc.
|
||||
for (const element of clone.querySelectorAll('*')) {
|
||||
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby']) {
|
||||
const value = element.getAttribute(name)
|
||||
if (value && /^_r_[a-z0-9]+_$/u.test(value)) {
|
||||
if (!ids.has(value)) ids.set(value, `react-id-${ids.size}`)
|
||||
element.setAttribute(name, ids.get(value)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone.innerHTML
|
||||
}
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1] }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const originalPath = '/src/renderer/src/MarkdownRenderer.tsx?streaming-original'
|
||||
const original = await import(/* @vite-ignore */ originalPath) as Instrumented
|
||||
const render = (Component: typeof current.MarkdownRenderer, content: string, renderHtml = false) => {
|
||||
flushSync(() => root.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<Component renderHtml={renderHtml}>{content}</Component>
|
||||
</UiLocaleProvider>))
|
||||
}
|
||||
// Warm fonts, styles, React, Markdown, and KaTeX before recording samples.
|
||||
render(current.MarkdownRenderer, performanceCorpus[1]!.content)
|
||||
await document.fonts.ready
|
||||
const results = []
|
||||
for (const corpus of performanceCorpus) {
|
||||
const prefixes = Array.from({ length: 24 }, (_, i) => corpus.content.slice(0, Math.ceil(corpus.content.length * (i + 1) / 24)))
|
||||
const renderTimes: number[] = []
|
||||
const normalizeTimes: number[] = []
|
||||
let checksum = 0
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
for (const prefix of prefixes) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
// Include the synchronous DOM commit and Chromium style/layout, not frame waiting.
|
||||
void host.offsetHeight
|
||||
if (round > 0) renderTimes.push(performance.now() - start)
|
||||
}
|
||||
}
|
||||
// Batch sub-millisecond calls to exceed Chromium timer resolution.
|
||||
for (let round = 0; round < 4; round++) {
|
||||
for (const prefix of prefixes) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) checksum += normalize(prefix).length
|
||||
if (round > 0) normalizeTimes.push((performance.now() - start) / 100)
|
||||
}
|
||||
}
|
||||
results.push({ corpus: corpus.name, characters: corpus.content.length, samples: renderTimes.length,
|
||||
renderMs: stats(renderTimes), normalizeMs: stats(normalizeTimes), checksum })
|
||||
}
|
||||
const originalHost = document.createElement('div')
|
||||
originalHost.className = host.className
|
||||
originalHost.style.cssText = host.style.cssText
|
||||
document.body.append(originalHost)
|
||||
const originalRoot = createRoot(originalHost)
|
||||
const longSamples = { original: [] as number[], current: [] as number[] }
|
||||
const prefixLengths = Array.from({ length: 6 }, (_, i) => Math.ceil(longAnswerCorpus.length * (i + 1) / 6))
|
||||
let longDomChecks = 0
|
||||
try {
|
||||
// One warm-up sweep, three measured sweeps: 48 renders total, on persistent roots.
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
for (const [index, length] of prefixLengths.entries()) {
|
||||
const prefix = longAnswerCorpus.slice(0, length)
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Long normalizer mismatch: ${length}`)
|
||||
const order = (round + index) % 2 === 0
|
||||
? ['original', 'current'] as const : ['current', 'original'] as const
|
||||
for (const variant of order) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
if (variant === 'original') {
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void originalHost.offsetHeight
|
||||
} else {
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
void host.offsetHeight
|
||||
}
|
||||
if (round > 0) longSamples[variant].push(performance.now() - start)
|
||||
}
|
||||
// Compare outside timing, including the complete 100 KiB answer on every sweep.
|
||||
if (snapshot() !== snapshot(originalHost)) throw new Error(`Long DOM mismatch: ${length}`)
|
||||
longDomChecks++
|
||||
}
|
||||
}
|
||||
if (!host.querySelector('h2') || !host.querySelector('pre code') || !host.querySelector('table')) {
|
||||
throw new Error('Long-answer prose/code/table formatting missing')
|
||||
}
|
||||
} finally {
|
||||
flushSync(() => originalRoot.unmount())
|
||||
originalHost.remove()
|
||||
}
|
||||
const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length,
|
||||
prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length,
|
||||
originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current),
|
||||
rawRenderMs: longSamples, domChecks: longDomChecks, finalDomEquivalent: true }
|
||||
let prefixesChecked = 0
|
||||
let domChecks = 0
|
||||
for (const content of equivalenceCorpus) {
|
||||
for (let end = 0; end <= content.length; end++) {
|
||||
const prefix = content.slice(0, end)
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`)
|
||||
prefixesChecked++
|
||||
for (const renderHtml of [false, true]) {
|
||||
render(original.MarkdownRenderer, prefix, renderHtml)
|
||||
const expected = snapshot()
|
||||
render(current.MarkdownRenderer, prefix, renderHtml)
|
||||
if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`)
|
||||
domChecks++
|
||||
}
|
||||
}
|
||||
}
|
||||
render(current.MarkdownRenderer, '# Live\n\n**formatted** and \\(x\\)')
|
||||
if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) {
|
||||
throw new Error('Real Markdown formatting missing')
|
||||
}
|
||||
return { results, longAnswer, prefixesChecked, domChecks }
|
||||
}
|
||||
|
||||
Object.assign(window, { streamingMarkdown: run })
|
||||
Reference in New Issue
Block a user