mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(app): bound prose append work and preserve markdown edge cases
This commit is contained in:
@@ -14,7 +14,7 @@ Checkbox toggling is disabled because changes would not persist. Admonitions add
|
||||
|
||||
## Compatibility constraints
|
||||
|
||||
- The 1.1.1 native parser drops entity nodes. Decode text entities before rendering, retaining literal entities inside code. Its autolinks also disagree with Paseo about bare domains and trailing punctuation, so preserve Paseo’s recognized destinations as explicit links.
|
||||
- The 1.1.1 native parser drops entity nodes. Decode text and destination entities before rendering, retaining literal entities inside code. Whitespace entities must remain text instead of creating new Markdown blocks. Its autolinks also disagree with Paseo about bare domains and trailing punctuation, so preserve Paseo’s recognized destinations as explicit links.
|
||||
- Keep dollar-delimited text literal with `latexMath: false`; dollar amounts must not become math.
|
||||
- Spoilers have no public parser switch in 1.1.1. Escape prose pipes so `||text||` stays visible; code and link destinations retain their pipes.
|
||||
- `lineHeight` is a minimum, so font metrics can increase it. Compare spacing on devices.
|
||||
|
||||
@@ -124,6 +124,59 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
|
||||
}
|
||||
|
||||
describe("native prose runs", () => {
|
||||
it("retains completed runs and their source arrays while the tail grows", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
state = applyStreamEvent({
|
||||
...state,
|
||||
event: assistant("First.\n\nSecond.\n\n```js\n1\n```\n\nTail."),
|
||||
timestamp: new Date(1000),
|
||||
});
|
||||
const before = rows(present({ ...presentationOptions, ...state, transform: undefined }));
|
||||
state = applyStreamEvent({ ...state, event: assistant(" More."), timestamp: new Date(1000) });
|
||||
const after = rows(present({ ...presentationOptions, ...state, transform: undefined }));
|
||||
expect(after[0]).toBe(before[0]);
|
||||
expect(after[1]).toBe(before[1]);
|
||||
expect(after[2]).not.toBe(before[2]);
|
||||
});
|
||||
it("does not duplicate retained prose around split images on append", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
let combined = "";
|
||||
for (const text of ["First.\n\nBefore  after.", "\n\nNext.", " More."]) {
|
||||
combined += text;
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
const live = rows(present({ ...presentationOptions, ...state, transform: undefined }));
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([{ event: assistant(combined), timestamp: new Date(1000) }]),
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(live).toEqual(history.tail);
|
||||
}
|
||||
});
|
||||
it("keeps 600 short-paragraph appends near blocks mode", { retry: 2 }, () => {
|
||||
function measure(markdown: "blocks" | "prose-runs") {
|
||||
const present = createStreamPresentation({ markdown });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 600; i++) {
|
||||
state = applyStreamEvent({
|
||||
...state,
|
||||
event: assistant("A short paragraph.\n\n".repeat(3).slice(0, 50)),
|
||||
timestamp: new Date(1000),
|
||||
});
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
return performance.now() - start;
|
||||
}
|
||||
measure("blocks");
|
||||
const blocks = measure("blocks");
|
||||
const prose = measure("prose-runs");
|
||||
expect(prose).toBeLessThan(Math.max(300, blocks * 3));
|
||||
expect(prose).toBeLessThan(1500);
|
||||
});
|
||||
it.each([
|
||||
"<!-- keep this -->\nText.",
|
||||
"<?pi hello?>",
|
||||
|
||||
@@ -151,17 +151,9 @@ export function createStreamPresentation({
|
||||
const cached = runsBySource.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
const previous = previousSource && runsBySource.get(previousSource);
|
||||
const previousById = new Map(previous?.map((row) => [row.id, row]));
|
||||
const assistantBlocks = blocks.filter(
|
||||
(block): block is AssistantMessageItem => block.kind === "assistant_message",
|
||||
);
|
||||
const runs = groupMarkdownProse(assistantBlocks).map((row) => {
|
||||
const existing = previousById.get(row.id);
|
||||
return existing?.text === row.text && isReusableBlock(existing, item, row.id)
|
||||
? existing
|
||||
: row;
|
||||
});
|
||||
const previousBlocks = previousSource && blocksBySource.get(previousSource);
|
||||
// nativeBlocks emits only assistant blocks for an assistant source.
|
||||
const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks);
|
||||
runsBySource.set(item, runs);
|
||||
return runs;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ describe("source-preserving prose", () => {
|
||||
"[linked `code`](https://example.net)",
|
||||
"Before  after.",
|
||||
"Blank ` ` code and ` normal ` code",
|
||||
"- first\n\tsecond",
|
||||
"\n\nA paragraph.\n\n",
|
||||
"- [ ] Task\n- [x] Done",
|
||||
"> [!NOTE]\n> Note",
|
||||
@@ -32,6 +31,54 @@ describe("source-preserving prose", () => {
|
||||
])("copies unchanged source byte-for-byte: %s", (source) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(source);
|
||||
});
|
||||
it.each([
|
||||
["[file](file:///repo/a&b.txt)", "[file](<file:///repo/a&b.txt>)"],
|
||||
["", ""],
|
||||
["- first\n\t& second", "- first\n & second"],
|
||||
["[foo\nbar]", "\\[foo\nbar]"],
|
||||
["[foo\\*bar]", "\\[foo\\*bar]"],
|
||||
])("preserves decoded destinations and source positions: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
[" ---", "\\\n\\---"],
|
||||
["Before # after", "Before\\\n\\# after"],
|
||||
[" 1. next", "\\\n1\\. next"],
|
||||
["	---", "\u00a0\u00a0\u00a0\u00a0---"],
|
||||
[" ---", "\u00a0---"],
|
||||
["Before after", "Before\\\nafter"],
|
||||
])("keeps whitespace entities inside text: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it("keeps multiline and escaped labels local to each source block", () => {
|
||||
expect(prepareProse(["[foo\nbar]", "Other", "[foo bar]: https://example.com"], noFiles)).toBe(
|
||||
"\\[foo\nbar]\n\nOther\n\n[foo bar]: https://example.com",
|
||||
);
|
||||
expect(
|
||||
prepareProse(["[foo\\*bar]", "Other", "[foo\\*bar]: https://example.com"], noFiles),
|
||||
).toBe("\\[foo\\*bar]\n\nOther\n\n[foo\\*bar]: https://example.com");
|
||||
});
|
||||
it("prepares only changed source blocks on append", () => {
|
||||
const candidates: string[] = [];
|
||||
const prepare = createProsePreparer((text) => {
|
||||
candidates.push(text);
|
||||
return false;
|
||||
});
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`."], true, Infinity);
|
||||
candidates.length = 0;
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], true, Infinity);
|
||||
expect(candidates).toEqual(["three"]);
|
||||
});
|
||||
it("bounds preparation before parsing a capped block", () => {
|
||||
let candidates = 0;
|
||||
const prepare = createProsePreparer(() => {
|
||||
candidates++;
|
||||
return false;
|
||||
});
|
||||
const source = "Use `foo` here. ".repeat(70_000);
|
||||
prepare([source], false, 32_000);
|
||||
expect(candidates).toBeLessThan(2200);
|
||||
});
|
||||
it("decodes entities outside code", () => {
|
||||
expect(prepareProse(["A & B A A © `&`"], noFiles)).toBe(
|
||||
"A & B A A © `&`",
|
||||
@@ -61,12 +108,12 @@ describe("source-preserving prose", () => {
|
||||
const tasks = "- [x] Task\n- [ ] Pending\n1. [x] Ordered";
|
||||
expect(prepareProse([tasks], noFiles)).toBe(tasks);
|
||||
});
|
||||
it("prepares 30k of inline code in under 10ms warm", () => {
|
||||
it("prepares 30k of inline code in under 50ms warm", () => {
|
||||
const text = "Use `foo` and `bar` here. ".repeat(1300).slice(0, 30000);
|
||||
for (let i = 0; i < 10; i++) prepareProse([text], noFiles);
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 5; i++) expect(prepareProse([text], noFiles)).toBe(text);
|
||||
expect((performance.now() - start) / 5).toBeLessThan(10);
|
||||
expect((performance.now() - start) / 5).toBeLessThan(50);
|
||||
});
|
||||
it("reuses prepared prefixes while completing the visible inline tail", () => {
|
||||
const prepare = createProsePreparer(noFiles);
|
||||
|
||||
@@ -87,20 +87,26 @@ interface PreparedBlock {
|
||||
|
||||
/** One cache per mounted prose row. Reveal reuses the prepared prefix and parses only its open tail. */
|
||||
export function createProsePreparer(canResolveCode: (text: string) => boolean) {
|
||||
let cache = new Map<string, PreparedBlock>();
|
||||
let cache = new Map<string, { source: string; prepared: PreparedBlock }>();
|
||||
return (blocks: readonly string[], streaming: boolean, visibleLength: number): string => {
|
||||
const retained = new Map<string, PreparedBlock>();
|
||||
const retained = new Map<string, { source: string; prepared: PreparedBlock }>();
|
||||
const result: string[] = [];
|
||||
let remaining = visibleLength;
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const live = streaming && index === blocks.length - 1;
|
||||
if (remaining < 0) break;
|
||||
const live = streaming && (index === blocks.length - 1 || remaining < block.length);
|
||||
const key = `${live}:${block}`;
|
||||
const prepared = cache.get(key) ?? compileBlock(block, canResolveCode, live);
|
||||
retained.set(key, prepared);
|
||||
if (remaining >= 0) {
|
||||
result.push(revealBlock(block, prepared, remaining, canResolveCode, streaming));
|
||||
remaining -= block.length + 2;
|
||||
}
|
||||
const source = block.slice(0, remaining);
|
||||
const cached = cache.get(key);
|
||||
const entry =
|
||||
cached && cached.source.length >= source.length
|
||||
? cached
|
||||
: { source, prepared: compileBlock(source, canResolveCode, live) };
|
||||
retained.set(key, entry);
|
||||
result.push(
|
||||
revealBlock(entry.source, entry.prepared, source.length, canResolveCode, streaming),
|
||||
);
|
||||
remaining -= block.length + 2;
|
||||
}
|
||||
cache = retained;
|
||||
return result.join("\n\n");
|
||||
@@ -187,7 +193,10 @@ function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: st
|
||||
const end = labelEnd(context, raw, 0);
|
||||
protect(context.protectedRanges, { start: span.start + end, end: span.end });
|
||||
protect(context.referenceRanges, span);
|
||||
if (raw[end + 1] === "(") return;
|
||||
if (raw[end + 1] === "(") {
|
||||
decodeDestination(context, span, raw, end, link.attrGet("href")!);
|
||||
return;
|
||||
}
|
||||
// The native parser sees the joined run; bind reference destinations to their source block.
|
||||
const title = link.attrGet("title");
|
||||
const quotedTitle =
|
||||
@@ -198,10 +207,52 @@ function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: st
|
||||
text: `](<${link.attrGet("href")}>${quotedTitle})`,
|
||||
});
|
||||
}
|
||||
function decodeDestination(
|
||||
context: InlinePreparation,
|
||||
span: InlineSpan,
|
||||
raw: string,
|
||||
label: number,
|
||||
href: string,
|
||||
) {
|
||||
if (raw[label + 1] !== "(") return;
|
||||
const start = label + 2 + (raw.slice(label + 2).match(/^\s*/)?.[0].length ?? 0);
|
||||
const destination = context.parser.helpers.parseLinkDestination(raw, start, raw.length);
|
||||
if (!destination.ok) return;
|
||||
const original = raw.slice(start, destination.pos);
|
||||
if (context.parser.utils.unescapeAll(original) === original) return;
|
||||
context.edits.push({
|
||||
start: span.start + start,
|
||||
end: span.start + destination.pos,
|
||||
text: `<${href}>`,
|
||||
});
|
||||
}
|
||||
function entityWhitespace(
|
||||
context: InlinePreparation,
|
||||
span: InlineSpan,
|
||||
decoded: string,
|
||||
): string | null {
|
||||
if (!/^[\t\n\r\f\v ]+$/.test(decoded)) return null;
|
||||
if (!/[\r\n]/.test(decoded))
|
||||
return decoded.replace(/\t/g, "\u00a0\u00a0\u00a0\u00a0").replace(/[\f\v ]/g, "\u00a0");
|
||||
// A text-token newline must not start a block. Backslash breaks also work at
|
||||
// the beginning of a paragraph, where two-space breaks would be trimmed away.
|
||||
const following = context.source.slice(span.end);
|
||||
const marker =
|
||||
/^[ \t]*(?:[-=]+[ \t]*(?:\n|$)|(?:[-*_][ \t]*){3,}(?:\n|$)|#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)][ \t]|[-+*][ \t]|`{3,}|~{3,}|<)/.exec(
|
||||
following,
|
||||
);
|
||||
if (marker) {
|
||||
const punctuation = marker[0].search(/[^ \t\d]/);
|
||||
insert(context, span.end + punctuation, "\\");
|
||||
}
|
||||
return decoded.replace(/\r\n?|\n/g, "\\\n");
|
||||
}
|
||||
function decodeEntity(context: InlinePreparation, span: InlineSpan, raw: string) {
|
||||
// MD4C 1.1.1 drops entity nodes. Decode once without introducing Markdown syntax.
|
||||
const decoded = context.parser.utils.unescapeAll(raw);
|
||||
let text = decoded.replace(/([\\`*{}[\]()#+.!_<>|~-])/g, "\\$1");
|
||||
let text =
|
||||
entityWhitespace(context, span, decoded) ??
|
||||
decoded.replace(/([\\`*{}[\]()#+.!_<>|~-])/g, "\\$1");
|
||||
if (decoded.endsWith("&") && /^(?:#\w+|\w+);/.test(context.source.slice(span.end)))
|
||||
text = text.slice(0, -1) + "\\&";
|
||||
context.edits.push({ start: span.start, end: span.end, text });
|
||||
@@ -212,7 +263,11 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
const link = span.tokens.find((child) => child.type === "link_open");
|
||||
const image = span.tokens.find((child) => child.type === "image");
|
||||
const code = span.tokens.find((child) => child.type === "code_inline");
|
||||
if (image || raw.startsWith("\\") || (link && raw.startsWith("<"))) {
|
||||
if (image) {
|
||||
decodeDestination(context, span, raw, labelEnd(context, raw, 1), image.attrGet("src")!);
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
} else if (raw.startsWith("\\") || (link && raw.startsWith("<"))) {
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
} else if (/^&(?:#\w+|\w+);$/.test(raw)) {
|
||||
@@ -232,19 +287,25 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
}
|
||||
}
|
||||
function preserveUnresolvedReferences(context: InlinePreparation) {
|
||||
// Unresolved labels stay literal even when another source block defines them.
|
||||
for (const match of context.source.matchAll(/!?\[[^\]\n]+\](?:\[[^\]\n]*\])?/g)) {
|
||||
if (contains(context.referenceRanges, match.index, match.index + match[0].length)) continue;
|
||||
if (/^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/.test(match[0])) continue;
|
||||
// Protect every unresolved label, regardless of CommonMark's case/whitespace
|
||||
// normalization. Escapes inside a label do not make its opening bracket safe.
|
||||
const openings: number[] = [];
|
||||
for (let index = 0; index < context.source.length; index++) {
|
||||
if (contains(context.referenceRanges, index)) continue;
|
||||
if (context.source[index] === "[") openings.push(index);
|
||||
if (context.source[index] !== "]") continue;
|
||||
const start = openings.pop();
|
||||
if (start === undefined) continue;
|
||||
const label = context.source.slice(start, index + 1);
|
||||
if (/^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/.test(label)) continue;
|
||||
if (
|
||||
context.task &&
|
||||
match.index === 0 &&
|
||||
/^\[[ xX]\]$/.test(match[0]) &&
|
||||
/\s/.test(context.source[match[0].length] ?? "")
|
||||
start === 0 &&
|
||||
/^\[[ xX]\]$/.test(label) &&
|
||||
/\s/.test(context.source[index + 1] ?? "")
|
||||
)
|
||||
continue;
|
||||
for (let i = 0; i < match[0].length; i++)
|
||||
if (match[0][i] === "[") insert(context, match.index + i, "\\");
|
||||
insert(context, start, "\\");
|
||||
}
|
||||
}
|
||||
function prepareText(context: InlinePreparation) {
|
||||
@@ -313,8 +374,14 @@ function compileBlock(
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming: boolean,
|
||||
): PreparedBlock {
|
||||
const normalized = !text.includes("\r");
|
||||
const normalized = !/[\r\t]/.test(text);
|
||||
text = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\ufffd");
|
||||
text = text.replace(/^[ \t>]*/gm, (prefix) => {
|
||||
let expanded = "";
|
||||
for (const char of prefix)
|
||||
expanded += char === "\t" ? " ".repeat(4 - (expanded.length % 4)) : char;
|
||||
return expanded;
|
||||
});
|
||||
const { parser, traces } = streaming ? provisional : settled;
|
||||
const env = {};
|
||||
const tokens = parser.parse(text, env);
|
||||
|
||||
@@ -65,25 +65,103 @@ function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Native selection belongs to a text view, so adjacent prose shares one display row. */
|
||||
export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMessageItem[] {
|
||||
const rows: AssistantMessageItem[] = [];
|
||||
for (const block of blocks.flatMap(splitBlock)) {
|
||||
const previous = rows.at(-1);
|
||||
if (
|
||||
block.markdownKind === "prose" &&
|
||||
previous?.markdownKind === "prose" &&
|
||||
previous.text.length + 2 + block.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
|
||||
) {
|
||||
previous.text += `\n\n${block.text}`;
|
||||
previous.markdownProseBlocks = [...previous.markdownProseBlocks!, block.text];
|
||||
continue;
|
||||
}
|
||||
interface GroupedRuns {
|
||||
rows: AssistantMessageItem[];
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
const runsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
|
||||
|
||||
/** Rebuild only the run containing the changed source block; completed runs retain identity. */
|
||||
export function groupMarkdownProse(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks?: readonly AssistantMessageItem[],
|
||||
): AssistantMessageItem[] {
|
||||
const cached = runsByBlocks.get(blocks);
|
||||
if (cached) return cached.rows;
|
||||
const previous = previousBlocks && runsByBlocks.get(previousBlocks);
|
||||
const retained = retainedRunCount(blocks, previousBlocks, previous);
|
||||
const start = previous?.starts[retained] ?? 0;
|
||||
const rows = previous?.rows.slice(0, retained) ?? [];
|
||||
const starts = previous?.starts.slice(0, retained) ?? [];
|
||||
const ends = previous?.ends.slice(0, retained) ?? [];
|
||||
let parts: AssistantMessageItem[] = [];
|
||||
let length = 0;
|
||||
let runStart = start;
|
||||
let runEnd = start;
|
||||
function flush() {
|
||||
if (!parts.length) return;
|
||||
const first = parts[0]!;
|
||||
const texts = parts.map((part) => part.text);
|
||||
rows.push({
|
||||
...block,
|
||||
markdownProseBlocks: block.markdownKind === "block" ? undefined : [block.text],
|
||||
id: block.markdownKind === "prose" ? block.id.replace(":block:", ":run:") : block.id,
|
||||
...first,
|
||||
text: texts.join("\n\n"),
|
||||
markdownProseBlocks: first.markdownKind === "block" ? undefined : texts,
|
||||
id: first.markdownKind === "prose" ? first.id.replace(":block:", ":run:") : first.id,
|
||||
});
|
||||
starts.push(runStart);
|
||||
ends.push(runEnd);
|
||||
parts = [];
|
||||
length = 0;
|
||||
}
|
||||
for (let index = start; index < blocks.length; index++) {
|
||||
for (const part of splitBlock(blocks[index]!)) {
|
||||
if (
|
||||
parts.length &&
|
||||
(part.markdownKind !== "prose" ||
|
||||
parts[0]!.markdownKind !== "prose" ||
|
||||
length + 2 + part.text.length > ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT)
|
||||
)
|
||||
flush();
|
||||
if (!parts.length) runStart = index;
|
||||
length += (parts.length ? 2 : 0) + part.text.length;
|
||||
parts.push(part);
|
||||
runEnd = index;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
reuseUnchangedRows(blocks, previousBlocks, previous, rows, starts, ends, retained);
|
||||
runsByBlocks.set(blocks, { rows, starts, ends });
|
||||
return rows;
|
||||
}
|
||||
|
||||
function retainedRunCount(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks: readonly AssistantMessageItem[] | undefined,
|
||||
previous: GroupedRuns | undefined,
|
||||
): number {
|
||||
if (!previous || !previousBlocks) return 0;
|
||||
let shared = 0;
|
||||
while (shared < blocks.length && blocks[shared] === previousBlocks[shared]) shared++;
|
||||
let retained = 0;
|
||||
// Revisit the boundary run because newly appended prose can join it.
|
||||
while (retained + 1 < previous.rows.length && previous.starts[retained + 1]! < shared) retained++;
|
||||
// Several image parts can originate from one source block. Rebuild them together.
|
||||
while (retained > 0 && previous.ends[retained - 1]! >= previous.starts[retained]!) retained--;
|
||||
return retained;
|
||||
}
|
||||
function reuseUnchangedRows(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks: readonly AssistantMessageItem[] | undefined,
|
||||
previous: GroupedRuns | undefined,
|
||||
rows: AssistantMessageItem[],
|
||||
starts: number[],
|
||||
ends: number[],
|
||||
retained: number,
|
||||
) {
|
||||
if (!previous || !previousBlocks) return;
|
||||
for (let index = retained; index < rows.length; index++) {
|
||||
const old = previous.rows[index];
|
||||
const row = rows[index]!;
|
||||
if (old?.id !== row.id || old.text !== row.text) continue;
|
||||
const end = ends[index]!;
|
||||
let identical = true;
|
||||
for (let block = starts[index]!; block <= end; block++)
|
||||
if (blocks[block] !== previousBlocks[block]) {
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
if (!identical) continue;
|
||||
rows[index] = old;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user