From fa4f2b8fcdab7f2c931fe4545b5a742982fe6bb8 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Sat, 28 Mar 2026 04:50:00 -0700 Subject: [PATCH] AIX: Reassembler: persistence-of-vision of 500ms for VP fragments Allows for a more natural read. --- src/modules/aix/client/ContentReassembler.ts | 51 ++++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/modules/aix/client/ContentReassembler.ts b/src/modules/aix/client/ContentReassembler.ts index eb290b9bb..9ebf663cc 100644 --- a/src/modules/aix/client/ContentReassembler.ts +++ b/src/modules/aix/client/ContentReassembler.ts @@ -21,8 +21,17 @@ import { aixClassifyReassemblyError } from './aix.client.errors'; // configuration const GENERATED_IMAGES_CONVERT_TO_COMPRESSED = true; // converts PNG to WebP or JPEG to save IndexedDB space const GENERATED_IMAGES_COMPRESSION_QUALITY = 0.98; -const ELLIPSIZE_DEV_ISSUE_MESSAGES = 4096; +const ELLIPSIZE_DEV_ISSUE_MESSAGES = 4096; // for _appendReassemblyDevError const MERGE_ISSUES_INTO_TEXT_PART_IF_OPEN = false; // 2025-10-10: put errors in the dedicated part +const VP_PERSISTENCE_DELAY = 500; // persistence of vision for voidPlaceholders + + +// Future: Reassembly Policies +// type ReassemblyPolicyVoidPlaceholder = +// | 'ephemeral-log' // (default) when message content arrives (reasoning, text, tool calls, images, etc..), remove the last VP +// | 'single-log-end' // move the VP to the end to have a single large log of operations +// | 'interleaved-log' // batch VP OPs, but interleave them with the other content +// ; /** @@ -99,7 +108,7 @@ export class ContentReassembler { // - remove placeholders for clean exists, leave them for issues or client-aborts if (this._terminationReason === 'done-dialect') - while (this.removeLastVoidPlaceholder()) {} // [PH-LIFECYCLE] + this._removeAllVoidPlaceholders(); // [PH-LIFECYCLE] // - mark as completed or errored for (const fragment of this.accumulator.fragments) @@ -112,7 +121,7 @@ export class ContentReassembler { } } - + // Metrics const hadIssues = !!this.accumulator.legacyGenTokenStopReason; metricsFinishChatGenerateLg(this.accumulator.genMetricsLg, hadIssues); @@ -240,7 +249,7 @@ export class ContentReassembler { // TextParticleOp case 't' in op: - this.removeLastVoidPlaceholder(); + await this._removeLastVoidPlaceholderDelayed(); this.onAppendText(op); break; @@ -248,7 +257,7 @@ export class ContentReassembler { case 'p' in op: // heuristics to remove the placeholder if real user-destined content arrives if (op.p !== '❤' && op.p !== 'vp' && op.p !== 'urlc' && op.p !== 'svs') - this.removeLastVoidPlaceholder(); + await this._removeLastVoidPlaceholderDelayed(); switch (op.p) { case '❤': // ignore the heartbeats @@ -314,10 +323,12 @@ export class ContentReassembler { this.onCGIssue(op); break; case 'aix-info': - this.onAixInfo(op); + await this._removeLastVoidPlaceholderDelayed(); + this.onAixInfo(op); // creates a voidPlaceholder break; case 'aix-retry-reset': - this.onAixRetryReset(op); + await this._removeLastVoidPlaceholderDelayed(); + this.onAixRetryReset(op); // creates a voidPlaceholder break; case 'set-metrics': this.onMetrics(op); @@ -698,16 +709,38 @@ export class ContentReassembler { }; } - private removeLastVoidPlaceholder(): boolean { + private _removeAllVoidPlaceholders(): void { + const fragments = this.accumulator.fragments; + for (let i = fragments.length - 1; i >= 0; i--) + if (isVoidPlaceholderFragment(fragments[i])) { + fragments.splice(i, 1); + if (this.currentTextFragmentIndex !== null && this.currentTextFragmentIndex > i) + this.currentTextFragmentIndex--; + } + } + + private async _removeLastVoidPlaceholderDelayed(): Promise { const fragments = this.accumulator.fragments; const idx = fragments.findLastIndex(isVoidPlaceholderFragment); if (idx < 0) return false; + // delay before removal + await new Promise(resolve => setTimeout(resolve, VP_PERSISTENCE_DELAY)); fragments.splice(idx, 1); if (this.currentTextFragmentIndex !== null && this.currentTextFragmentIndex > idx) this.currentTextFragmentIndex--; return true; } + // private removeLastVoidPlaceholder(): boolean { + // const fragments = this.accumulator.fragments; + // const idx = fragments.findLastIndex(isVoidPlaceholderFragment); + // if (idx < 0) return false; + // fragments.splice(idx, 1); + // if (this.currentTextFragmentIndex !== null && this.currentTextFragmentIndex > idx) + // this.currentTextFragmentIndex--; + // return true; + // } + /// Rest of the data /// @@ -809,7 +842,6 @@ export class ContentReassembler { private onAixInfo({ ait, text }: Extract): void { // -> ph: show info - this.removeLastVoidPlaceholder(); this.accumulator.fragments.push(createPlaceholderVoidFragment(text, undefined, { ctl: 'ac-info', ait: ait, @@ -833,7 +865,6 @@ export class ContentReassembler { // -> ph: show retry status const retryMessage = `Retrying [${attempt}/${maxAttempts}] in ${Math.round(delayMs / 100) / 10}s - ${reason}`; - this.removeLastVoidPlaceholder(); this.accumulator.fragments.push(createPlaceholderVoidFragment(retryMessage, undefined, { ctl: 'ec-retry', rScope: rScope,