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:
mesalogo
2026-09-28 20:53:43 +08:00
parent 594222a2b5
commit 13ff182858
23 changed files with 1047 additions and 54 deletions
+9 -4
View File
@@ -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'))
+11 -1
View File
@@ -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
+91
View File
@@ -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'
-2
View File
@@ -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('已被更新')
})
+25 -11
View File
@@ -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 })
}
}
}
+43
View File
@@ -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: '活动时间线' }))
+3 -3
View File
@@ -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>
)
}
})
+74
View File
@@ -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
View File
@@ -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}
+91
View File
@@ -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[] = [
{
+6 -6
View File
@@ -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 =>
+22 -1
View File
@@ -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) => {
+8 -1
View File
@@ -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: '`' | '~'