mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
fix(app): keep growing markdown on the streaming block renderer
This commit is contained in:
@@ -146,7 +146,9 @@ describe("native prose runs", () => {
|
||||
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 live = rows(
|
||||
present({ ...presentationOptions, ...state, isTurnActive: false, transform: undefined }),
|
||||
);
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([{ event: assistant(combined), timestamp: new Date(1000) }]),
|
||||
@@ -156,7 +158,13 @@ describe("native prose runs", () => {
|
||||
expect(live).toEqual(history.tail);
|
||||
}
|
||||
});
|
||||
it("keeps 600 short-paragraph appends near blocks mode", { retry: 2 }, () => {
|
||||
it.each([
|
||||
["short paragraphs", "A short paragraph.\n\n".repeat(3).slice(0, 50)],
|
||||
[
|
||||
"one growing paragraph",
|
||||
"Use `foo` with https://example.com/path and `bar`. ".repeat(2).slice(0, 50),
|
||||
],
|
||||
])("keeps 600 appends near blocks mode: %s", { retry: 2 }, (_name, chunk) => {
|
||||
function measure(markdown: "blocks" | "prose-runs") {
|
||||
const present = createStreamPresentation({ markdown });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
@@ -164,7 +172,7 @@ describe("native prose runs", () => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
state = applyStreamEvent({
|
||||
...state,
|
||||
event: assistant("A short paragraph.\n\n".repeat(3).slice(0, 50)),
|
||||
event: assistant(chunk),
|
||||
timestamp: new Date(1000),
|
||||
});
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
@@ -201,7 +209,12 @@ describe("native prose runs", () => {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const live = present({ ...presentationOptions, ...state, transform: undefined });
|
||||
const live = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown })({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([
|
||||
@@ -242,7 +255,12 @@ describe("native prose runs", () => {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const live = present({ ...presentationOptions, ...state, transform: undefined });
|
||||
const live = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
expect([...live.tail, ...live.head]).toMatchObject([
|
||||
{ id: "message-1:run:0", text: first },
|
||||
{ id: "message-1:run:1", text: second },
|
||||
@@ -315,23 +333,65 @@ describe("native prose runs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("grows the live run and retains promoted rows", () => {
|
||||
it.each(["> Quoted text.", "- List item", "### Heading", "Plain paragraph."])(
|
||||
"classifies the final block at turn completion: %s",
|
||||
(text) => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
const head = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
expect(
|
||||
present({ ...presentationOptions, tail: [], head, transform: undefined }).head,
|
||||
).toMatchObject([{ markdownKind: "block" }]);
|
||||
const completed = present({
|
||||
...presentationOptions,
|
||||
isTurnActive: false,
|
||||
tail: [],
|
||||
head,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: head,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(rows(completed)).toEqual(history.tail);
|
||||
},
|
||||
);
|
||||
it("keeps the growing block separate and merges completed blocks into a stable run", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
function send(text: string) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
return present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const first = send("First.");
|
||||
expect(send("First.").head).toMatchObject([{ id: "message-1:block:0", markdownKind: "block" }]);
|
||||
const second = send("\n\nSecond.");
|
||||
expect(second.tail).toEqual([]);
|
||||
expect(second.head).toMatchObject([{ id: first.head[0]!.id, text: "First.\n\nSecond." }]);
|
||||
const boundary = send("\n\n```ts\na\n```");
|
||||
expect(boundary.tail[0]).toBe(second.head[0]);
|
||||
const last = send("\n\nAfter.");
|
||||
expect(last.tail[0]).toBe(second.head[0]);
|
||||
expect(last.tail[1]).toBe(boundary.head[0]);
|
||||
expect(last.head).toMatchObject([{ id: "message-1:run:3", text: "After." }]);
|
||||
expect(second.tail).toMatchObject([
|
||||
{ id: "message-1:run:0", text: "First.", markdownKind: "prose" },
|
||||
]);
|
||||
expect(second.head).toMatchObject([
|
||||
{ id: "message-1:block:1", text: "Second.", markdownKind: "block" },
|
||||
]);
|
||||
const growing = send(" More.");
|
||||
expect(growing.tail[0]).toBe(second.tail[0]);
|
||||
const third = send("\n\nThird.");
|
||||
expect(third.tail).toMatchObject([{ id: "message-1:run:0", text: "First.\n\nSecond. More." }]);
|
||||
const completed = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: [...state.tail, ...state.head],
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(rows(completed)).toEqual(history.tail);
|
||||
expect(rows(completed)).toMatchObject([
|
||||
{ id: "message-1:run:0", text: "First.\n\nSecond. More.\n\nThird." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps consecutive paragraphs in one selectable row", () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createStreamPresentation({
|
||||
markdown = "blocks",
|
||||
}: { markdown?: "blocks" | "prose-runs" } = {}) {
|
||||
const runsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
const growingRunsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
|
||||
function presentUserMessage(item: UserMessageItem): UserMessageItem {
|
||||
@@ -145,16 +146,17 @@ export function createStreamPresentation({
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
function presentRows(item: StreamItem, growing = false): StreamItem[] {
|
||||
const blocks = nativeBlocks(item);
|
||||
if (markdown === "blocks" || item.kind !== "assistant_message") return blocks;
|
||||
const cached = runsBySource.get(item);
|
||||
const cache = growing ? growingRunsBySource : runsBySource;
|
||||
const cached = cache.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
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);
|
||||
const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks, growing);
|
||||
cache.set(item, runs);
|
||||
return runs;
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ export function createStreamPresentation({
|
||||
// `blocksBySource` keeps both the live block identities and the split cost
|
||||
// from being paid again when only the tail's array identity changed.
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
presentRows,
|
||||
(item) => presentRows(item),
|
||||
);
|
||||
historySource = input.tail;
|
||||
historyTransform = input.transform;
|
||||
@@ -174,7 +176,7 @@ export function createStreamPresentation({
|
||||
const promoted: StreamItem[] = [];
|
||||
const nextLiveSources = new Map<string, AssistantMessageItem>();
|
||||
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
|
||||
const blocks = presentRows(item);
|
||||
const blocks = presentRows(item, input.isTurnActive);
|
||||
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
|
||||
if (item.kind === "assistant_message" && blocks.length > 1) {
|
||||
promoted.push(...blocks.slice(0, -1));
|
||||
|
||||
@@ -20,13 +20,6 @@ describe("source-preserving prose", () => {
|
||||
])("uses parser-owned link ranges: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["Use ~", "Use "],
|
||||
["Use ~/", "Use \\~/"],
|
||||
["Use ~/.config", "Use \\~/.config"],
|
||||
])("keeps provisional tilde escapes attached: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles, true)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."],
|
||||
["~/a ~/b", "\\~/a \\~/b"],
|
||||
@@ -35,7 +28,6 @@ describe("source-preserving prose", () => {
|
||||
["`~~` and `~/a ~/b`", "`~~` and `~/a ~/b`"],
|
||||
])("keeps single tildes literal: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
expect(prepareProse([source], noFiles, true)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["https://example.com/a&b", "[https://example.com/a&b](<https://example.com/a&b>)"],
|
||||
@@ -99,9 +91,9 @@ describe("source-preserving prose", () => {
|
||||
candidates.push(text);
|
||||
return false;
|
||||
});
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`."], true, Infinity);
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`."], Infinity);
|
||||
candidates.length = 0;
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], true, Infinity);
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], Infinity);
|
||||
expect(candidates).toEqual(["three"]);
|
||||
});
|
||||
it("bounds preparation before parsing a capped block", () => {
|
||||
@@ -111,7 +103,7 @@ describe("source-preserving prose", () => {
|
||||
return false;
|
||||
});
|
||||
const source = "Use `foo` here. ".repeat(70_000);
|
||||
prepare([source], false, 32_000);
|
||||
prepare([source], 32_000);
|
||||
expect(candidates).toBeLessThan(2200);
|
||||
});
|
||||
it("decodes entities outside code", () => {
|
||||
@@ -150,25 +142,6 @@ describe("source-preserving prose", () => {
|
||||
for (let i = 0; i < 5; i++) expect(prepareProse([text], noFiles)).toBe(text);
|
||||
expect((performance.now() - start) / 5).toBeLessThan(50);
|
||||
});
|
||||
it("reuses prepared prefixes while completing the visible inline tail", () => {
|
||||
const prepare = createProsePreparer(noFiles);
|
||||
const blocks = ["First & paragraph.", "Use `foo` and **bold** here. ".repeat(30)];
|
||||
const source = blocks.join("\n\n");
|
||||
prepare(blocks, true, source.length);
|
||||
for (let length = 0; length < source.length; length += 7) {
|
||||
expect(prepare(blocks, true, length)).toBe(prepareProse(blocks, noFiles, true, length));
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
["A ||literal|| and escaped \\* text.", 2, "A "],
|
||||
["Use `README.md` and example.com. Later.", 4, "Use "],
|
||||
["[unresolved label] later.", 12, "unresolved "],
|
||||
["[unresolved label] later.", 19, "unresolved label "],
|
||||
] as const)("keeps cached reveal boundaries literal: %s", (source, length, expected) => {
|
||||
const prepare = createProsePreparer((text) => text === "README.md");
|
||||
prepare([source], true, source.length);
|
||||
expect(prepare([source], true, length)).toBe(expected);
|
||||
});
|
||||
it("changes only prose pipes", () => {
|
||||
expect(prepareProse(["Keep ||literal|| and `a || b` and [a|b](https://x/a|b)."], noFiles)).toBe(
|
||||
"Keep \\|\\|literal\\|\\| and `a || b` and [a\\|b](https://x/a|b).",
|
||||
@@ -194,18 +167,6 @@ describe("source-preserving prose", () => {
|
||||
),
|
||||
).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &");
|
||||
});
|
||||
it.each([
|
||||
["Before **bold", "Before **bold**"],
|
||||
["Before `code", "Before `code`"],
|
||||
["Before [label](https://exa", "Before label"],
|
||||
["### A **heading", "### A **heading**"],
|
||||
["- First\n- **Second", "- First\n- **Second**"],
|
||||
["> First\n> **Second", "> First\n> **Second**"],
|
||||
["Before **", "Before "],
|
||||
["Before ~~strike", "Before ~~strike~~"],
|
||||
])("minimally completes the streaming tail: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles, true)).toBe(expected);
|
||||
});
|
||||
it("prepares source blocks independently, even beside a reference definition", () => {
|
||||
const blocks = ["![shot][pic]", "Other paragraph.\n\n[pic]: shots/app.png"];
|
||||
expect(prepareProse(blocks, noFiles)).toBe(
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import { type InlineSpan, type InlineTrace, createSourceParser } from "./source-inline";
|
||||
|
||||
const settled = createSourceParser(false);
|
||||
const provisional = createSourceParser(true);
|
||||
const settled = createSourceParser();
|
||||
const INLINE_CODE_SCHEME = "paseo-inline-code:";
|
||||
const FILE_LINK_SCHEME = "paseo-file-link:";
|
||||
|
||||
@@ -58,83 +57,34 @@ function applyEdits(source: string, edits: Edit[]): string {
|
||||
return result + source.slice(cursor);
|
||||
}
|
||||
|
||||
/** Source blocks are presentation's canonical inputs, never reconstructed from a joined run. */
|
||||
/** Each completed source block retains its own reference scope. */
|
||||
export function prepareProse(
|
||||
blocks: readonly string[],
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming = false,
|
||||
visibleLength = Infinity,
|
||||
): string {
|
||||
const visible: string[] = [];
|
||||
let remaining = visibleLength;
|
||||
for (const block of blocks) {
|
||||
if (remaining < 0) break;
|
||||
visible.push(block.slice(0, remaining));
|
||||
remaining -= block.length + 2;
|
||||
}
|
||||
return visible
|
||||
.map((block, index) =>
|
||||
prepareBlock(block, canResolveCode, streaming && index === visible.length - 1),
|
||||
)
|
||||
.join("\n\n");
|
||||
return createProsePreparer(canResolveCode)(blocks, visibleLength);
|
||||
}
|
||||
|
||||
interface PreparedBlock {
|
||||
text: string;
|
||||
cuts: number[];
|
||||
offsets: number[];
|
||||
}
|
||||
|
||||
/** One cache per mounted prose row. Reveal reuses the prepared prefix and parses only its open tail. */
|
||||
/** One cache per prose row; joining a completed block does not reparse earlier blocks. */
|
||||
export function createProsePreparer(canResolveCode: (text: string) => boolean) {
|
||||
let cache = new Map<string, { source: string; prepared: PreparedBlock }>();
|
||||
return (blocks: readonly string[], streaming: boolean, visibleLength: number): string => {
|
||||
const retained = new Map<string, { source: string; prepared: PreparedBlock }>();
|
||||
let cache = new Map<string, string>();
|
||||
return (blocks: readonly string[], visibleLength = Infinity): string => {
|
||||
const retained = new Map<string, string>();
|
||||
const result: string[] = [];
|
||||
let remaining = visibleLength;
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
for (const block of blocks) {
|
||||
if (remaining < 0) break;
|
||||
const live = streaming && (index === blocks.length - 1 || remaining < block.length);
|
||||
const key = `${live}:${block}`;
|
||||
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),
|
||||
);
|
||||
const prepared = cache.get(source) ?? prepareBlock(source, canResolveCode);
|
||||
retained.set(source, prepared);
|
||||
result.push(prepared);
|
||||
remaining -= block.length + 2;
|
||||
}
|
||||
cache = retained;
|
||||
return result.join("\n\n");
|
||||
};
|
||||
}
|
||||
function revealBlock(
|
||||
source: string,
|
||||
prepared: PreparedBlock,
|
||||
length: number,
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
if (length >= source.length) return prepared.text;
|
||||
// Checkpoints are outside inline containers, at whitespace in a plain paragraph.
|
||||
let low = 0;
|
||||
let high = prepared.cuts.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (prepared.cuts[mid]! <= length) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const index = low - 1;
|
||||
if (index < 0) return prepareBlock(source.slice(0, length), canResolveCode, streaming);
|
||||
const cut = prepared.cuts[index]!;
|
||||
// Retain paragraph context if the tail happens to begin with a block marker.
|
||||
const tail = prepareBlock(`x ${source.slice(cut, length)}`, canResolveCode, streaming).slice(2);
|
||||
return prepared.text.slice(0, prepared.offsets[index]) + tail;
|
||||
}
|
||||
|
||||
interface InlinePreparation {
|
||||
source: string;
|
||||
@@ -142,7 +92,6 @@ interface InlinePreparation {
|
||||
trace: InlineTrace;
|
||||
parser: typeof settled.parser;
|
||||
env: object;
|
||||
streaming: boolean;
|
||||
uppercase: boolean;
|
||||
canResolveCode: (text: string) => boolean;
|
||||
protectedRanges: Uint8Array;
|
||||
@@ -166,29 +115,19 @@ function codeUrl(context: InlinePreparation, content: string): string | null {
|
||||
const match = matches?.length === 1 ? matches[0] : undefined;
|
||||
return match?.index === 0 && match.lastIndex === content.length ? match.url : null;
|
||||
}
|
||||
function prepareCode(context: InlinePreparation, span: InlineSpan, raw: string, code: Token) {
|
||||
function prepareCode(context: InlinePreparation, span: InlineSpan, code: Token) {
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
const complete = raw.length >= code.markup.length * 2 && raw.endsWith(code.markup);
|
||||
const closing = complete ? "" : code.markup.slice(raw.match(/`+$/)?.[0].length ?? 0);
|
||||
const url = codeUrl(context, code.content);
|
||||
if (!span.insideLink && url) {
|
||||
insert(context, span.start, "[");
|
||||
insert(context, span.end, `${closing}](<${url}>)`);
|
||||
} else if (closing) insert(context, span.end, closing);
|
||||
insert(context, span.end, `](<${url}>)`);
|
||||
}
|
||||
}
|
||||
function labelEnd(context: InlinePreparation, source: string, start: number): number {
|
||||
const state = new context.parser.inline.State(source, context.parser, context.env, []);
|
||||
return context.parser.helpers.parseLinkLabel(state, start, false);
|
||||
}
|
||||
function prepareUnfinishedLink(context: InlinePreparation, span: InlineSpan, raw: string) {
|
||||
const start = raw.startsWith("![") ? 1 : 0;
|
||||
const end = labelEnd(context, raw, start);
|
||||
context.edits.push({ start: span.start, end: span.start + start + 1, text: "" });
|
||||
if (start === 1) context.edits.push({ start: span.start + 2, end: span.end, text: "" });
|
||||
else if (end !== -1) context.edits.push({ start: span.start + end, end: span.end, text: "" });
|
||||
protect(context.referenceRanges, span);
|
||||
}
|
||||
function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: string, link: Token) {
|
||||
const end = labelEnd(context, raw, 0);
|
||||
protect(context.protectedRanges, { start: span.start + end, end: span.end });
|
||||
@@ -275,22 +214,10 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
} else if (link && raw.startsWith("[")) {
|
||||
preserveLinkScope(context, span, raw, link);
|
||||
} else if (code && raw.startsWith("`")) {
|
||||
prepareCode(context, span, raw, code);
|
||||
} else if (
|
||||
context.streaming &&
|
||||
!contains(context.referenceRanges, span.start) &&
|
||||
(raw.startsWith("[") || raw.startsWith("!["))
|
||||
) {
|
||||
prepareUnfinishedLink(context, span, raw);
|
||||
} else if (
|
||||
context.streaming &&
|
||||
span.tokens.length === 0 &&
|
||||
/^(?:\*{1,3}|_{1,3}|~{1,2}|`+)$/.test(raw)
|
||||
) {
|
||||
context.edits.push({ start: span.start, end: span.end, text: "" });
|
||||
protect(context.protectedRanges, span);
|
||||
prepareCode(context, span, code);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveUnresolvedReferences(context: InlinePreparation) {
|
||||
// Protect every unresolved label, regardless of CommonMark's case/whitespace
|
||||
// normalization. Escapes inside a label do not make its opening bracket safe.
|
||||
@@ -421,19 +348,7 @@ function sourcePositions(
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
function prepareBlock(
|
||||
text: string,
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
return compileBlock(text, canResolveCode, streaming).text;
|
||||
}
|
||||
function compileBlock(
|
||||
text: string,
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming: boolean,
|
||||
): PreparedBlock {
|
||||
const normalized = !/[\r\t]/.test(text);
|
||||
function prepareBlock(text: string, canResolveCode: (text: string) => boolean): string {
|
||||
text = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\ufffd");
|
||||
text = text.replace(/^[ \t>]*/gm, (prefix) => {
|
||||
let expanded = "";
|
||||
@@ -441,7 +356,7 @@ function compileBlock(
|
||||
expanded += char === "\t" ? " ".repeat(4 - (expanded.length % 4)) : char;
|
||||
return expanded;
|
||||
});
|
||||
const { parser, traces } = streaming ? provisional : settled;
|
||||
const { parser, traces } = settled;
|
||||
const env = {};
|
||||
const tokens = parser.parse(text, env);
|
||||
const lines = text.split("\n");
|
||||
@@ -452,17 +367,9 @@ function compileBlock(
|
||||
offset += line.length + 1;
|
||||
}
|
||||
const edits: Edit[] = [];
|
||||
let cuts: number[] = [];
|
||||
for (const [index, token] of tokens.entries()) {
|
||||
if (token.type !== "inline" || !token.map) continue;
|
||||
const trace = traces.get(token)!;
|
||||
if (
|
||||
normalized &&
|
||||
text === token.content &&
|
||||
tokens.length === 3 &&
|
||||
tokens[0]!.type === "paragraph_open"
|
||||
)
|
||||
cuts = paragraphCuts(token.content, trace);
|
||||
const heading = tokens[index - 1];
|
||||
const context: InlinePreparation = {
|
||||
source: token.content,
|
||||
@@ -470,7 +377,6 @@ function compileBlock(
|
||||
trace,
|
||||
parser,
|
||||
env,
|
||||
streaming,
|
||||
uppercase: heading?.type === "heading_open" && heading.tag === "h6",
|
||||
canResolveCode,
|
||||
protectedRanges: new Uint8Array(token.content.length),
|
||||
@@ -478,59 +384,17 @@ function compileBlock(
|
||||
task: tokens[index - 2]?.type === "list_item_open",
|
||||
edits: [],
|
||||
};
|
||||
// Link/code ownership is established before either references or provisional
|
||||
// brackets can be rewritten. Linkify uses the parser's emphasis-split text tokens.
|
||||
// Link/code ownership is established before references can be rewritten. Linkify uses the parser's emphasis-split text tokens.
|
||||
for (const span of trace.spans)
|
||||
if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type)))
|
||||
protect(context.referenceRanges, span);
|
||||
prepareLinkifiedLinks(context);
|
||||
for (const span of trace.spans) prepareSpan(context, span);
|
||||
if (trace.trim) {
|
||||
const tail = { start: token.content.length - trace.trim, end: token.content.length };
|
||||
context.edits.push({ ...tail, text: "" });
|
||||
protect(context.protectedRanges, tail);
|
||||
}
|
||||
preserveUnresolvedReferences(context);
|
||||
prepareText(context);
|
||||
if (trace.suffix) insert(context, token.content.length, trace.suffix);
|
||||
const positions = sourcePositions(token, heading, lines, lineOffsets);
|
||||
for (const edit of context.edits)
|
||||
edits.push({ ...edit, start: positions[edit.start]!, end: positions[edit.end]! });
|
||||
}
|
||||
const rendered = applyEdits(text, edits);
|
||||
return { text: rendered, cuts, offsets: preparedOffsets(cuts, edits) };
|
||||
}
|
||||
function preparedOffsets(cuts: number[], edits: Edit[]): number[] {
|
||||
const offsets: number[] = [];
|
||||
let editIndex = 0;
|
||||
let delta = 0;
|
||||
for (const cut of cuts) {
|
||||
while (
|
||||
editIndex < edits.length &&
|
||||
edits[editIndex]!.end <= cut &&
|
||||
edits[editIndex]!.start < cut
|
||||
) {
|
||||
const edit = edits[editIndex++]!;
|
||||
delta += edit.text.length - (edit.end - edit.start);
|
||||
}
|
||||
offsets.push(cut + delta);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
function paragraphCuts(source: string, trace: InlineTrace): number[] {
|
||||
const cuts: number[] = [];
|
||||
let depth = 0;
|
||||
// A bracket can remain a provisional reference/link even after its closing bracket.
|
||||
// Keep that suffix together until the block settles.
|
||||
const bracket = source.indexOf("[");
|
||||
const stableEnd = bracket < 0 ? source.length : bracket;
|
||||
for (const span of trace.spans) {
|
||||
if (span.insideLink) continue;
|
||||
if (depth === 0 && span.tokens.length === 0 && source[span.start] !== "&") {
|
||||
for (let i = span.start; i < span.end; i++)
|
||||
if (i < stableEnd && source[i] === " ") cuts.push(i + 1);
|
||||
}
|
||||
for (const token of span.tokens) depth += token.nesting;
|
||||
}
|
||||
return cuts;
|
||||
return applyEdits(text, edits);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from "react";
|
||||
export interface MarkdownProseProps {
|
||||
text: string;
|
||||
sourceBlocks: readonly string[];
|
||||
streaming: boolean;
|
||||
kind: "prose" | "quote" | "block";
|
||||
onLinkPress: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
|
||||
@@ -96,7 +96,6 @@ const PROSE_CONTAINER = { marginBottom: -12 };
|
||||
const NativeProse = memo(function NativeProse({
|
||||
text,
|
||||
sourceBlocks,
|
||||
streaming,
|
||||
kind,
|
||||
onLinkPress,
|
||||
}: MarkdownProseProps) {
|
||||
@@ -106,8 +105,8 @@ const NativeProse = memo(function NativeProse({
|
||||
);
|
||||
const prepare = useMemo(() => createProsePreparer(canResolveCode), [canResolveCode]);
|
||||
const markdown = useMemo(
|
||||
() => prepare(sourceBlocks, streaming, text.length),
|
||||
[text.length, sourceBlocks, prepare, streaming],
|
||||
() => prepare(sourceBlocks, text.length),
|
||||
[text.length, sourceBlocks, prepare],
|
||||
);
|
||||
const handleLinkPress = useStableEvent(({ url }: { url: string }) => {
|
||||
handleProseLinkPress(url, (source) => actions.open(source, "preferred"), onLinkPress);
|
||||
|
||||
@@ -66,21 +66,25 @@ function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem
|
||||
}
|
||||
|
||||
interface GroupedRuns {
|
||||
growing: boolean;
|
||||
rows: AssistantMessageItem[];
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
const runsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
|
||||
const growingRunsByBlocks = 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[],
|
||||
growing = false,
|
||||
): AssistantMessageItem[] {
|
||||
const cached = runsByBlocks.get(blocks);
|
||||
const cache = growing ? growingRunsByBlocks : runsByBlocks;
|
||||
const cached = cache.get(blocks);
|
||||
if (cached) return cached.rows;
|
||||
const previous = previousBlocks && runsByBlocks.get(previousBlocks);
|
||||
const retained = retainedRunCount(blocks, previousBlocks, previous);
|
||||
const previous = previousGroups(previousBlocks);
|
||||
const retained = retainedRunCount(blocks, previousBlocks, previous, growing);
|
||||
const start = previous?.starts[retained] ?? 0;
|
||||
const rows = previous?.rows.slice(0, retained) ?? [];
|
||||
const starts = previous?.starts.slice(0, retained) ?? [];
|
||||
@@ -105,14 +109,13 @@ export function groupMarkdownProse(
|
||||
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();
|
||||
const block = blocks[index]!;
|
||||
const partsForBlock =
|
||||
growing && index === blocks.length - 1
|
||||
? [{ ...block, markdownKind: "block" as const }]
|
||||
: splitBlock(block);
|
||||
for (const part of partsForBlock) {
|
||||
if (parts.length && !canJoinRun(parts[0]!, part, length)) flush();
|
||||
if (!parts.length) runStart = index;
|
||||
length += (parts.length ? 2 : 0) + part.text.length;
|
||||
parts.push(part);
|
||||
@@ -121,7 +124,7 @@ export function groupMarkdownProse(
|
||||
}
|
||||
flush();
|
||||
reuseUnchangedRows(blocks, previousBlocks, previous, rows, starts, ends, retained);
|
||||
runsByBlocks.set(blocks, { rows, starts, ends });
|
||||
cache.set(blocks, { rows, starts, ends, growing });
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -129,15 +132,20 @@ function retainedRunCount(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks: readonly AssistantMessageItem[] | undefined,
|
||||
previous: GroupedRuns | undefined,
|
||||
growing: boolean,
|
||||
): number {
|
||||
if (!previous || !previousBlocks) return 0;
|
||||
let shared = 0;
|
||||
while (shared < blocks.length && blocks[shared] === previousBlocks[shared]) shared++;
|
||||
if (previous.growing && (!growing || blocks.length !== previousBlocks.length))
|
||||
shared = Math.min(shared, previousBlocks.length - 1);
|
||||
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--;
|
||||
while (retained < previous.rows.length && previous.ends[retained]! < shared) retained++;
|
||||
// Only completed blocks can merge with the preceding run. The live tail is a
|
||||
// hard boundary, so appending to it never rebuilds completed prose.
|
||||
if (!(growing && shared === blocks.length - 1)) retained = Math.max(0, retained - 1);
|
||||
while (retained > 0 && previous.ends[retained - 1]! >= (previous.starts[retained] ?? shared))
|
||||
retained--;
|
||||
return retained;
|
||||
}
|
||||
function reuseUnchangedRows(
|
||||
@@ -153,7 +161,8 @@ function reuseUnchangedRows(
|
||||
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;
|
||||
if (old?.id !== row.id || old.text !== row.text || old.markdownKind !== row.markdownKind)
|
||||
continue;
|
||||
const end = ends[index]!;
|
||||
let identical = true;
|
||||
for (let block = starts[index]!; block <= end; block++)
|
||||
@@ -165,3 +174,21 @@ function reuseUnchangedRows(
|
||||
rows[index] = old;
|
||||
}
|
||||
}
|
||||
|
||||
function canJoinRun(
|
||||
first: AssistantMessageItem,
|
||||
next: AssistantMessageItem,
|
||||
length: number,
|
||||
): boolean {
|
||||
return (
|
||||
first.markdownKind === "prose" &&
|
||||
next.markdownKind === "prose" &&
|
||||
length + 2 + next.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
function previousGroups(
|
||||
blocks: readonly AssistantMessageItem[] | undefined,
|
||||
): GroupedRuns | undefined {
|
||||
return blocks && (growingRunsByBlocks.get(blocks) ?? runsByBlocks.get(blocks));
|
||||
}
|
||||
|
||||
@@ -10,19 +10,17 @@ export interface InlineSpan {
|
||||
export interface InlineTrace {
|
||||
spans: InlineSpan[];
|
||||
linkifyTexts: string[];
|
||||
suffix: string;
|
||||
trim: number;
|
||||
}
|
||||
const TRACE = Symbol("prose source positions");
|
||||
|
||||
/** Observe the actual inline rules, including nested labels, without reprinting tokens. */
|
||||
export function createSourceParser(streaming: boolean) {
|
||||
const parser = createAssistantMarkdownParser({ streaming });
|
||||
export function createSourceParser() {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
const traces = new WeakMap<Token, InlineTrace>();
|
||||
const inlineTraces = new WeakMap<Token[], InlineTrace>();
|
||||
const parseInline = parser.inline.parse.bind(parser.inline);
|
||||
parser.inline.parse = (source, md, env, output) => {
|
||||
const trace: InlineTrace = { spans: [], linkifyTexts: [], suffix: "", trim: 0 };
|
||||
const trace: InlineTrace = { spans: [], linkifyTexts: [] };
|
||||
parseInline(source, md, { ...env, [TRACE]: trace }, output);
|
||||
inlineTraces.set(output, trace);
|
||||
};
|
||||
@@ -45,30 +43,6 @@ export function createSourceParser(streaming: boolean) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (streaming) {
|
||||
const counts = new WeakMap<
|
||||
object,
|
||||
{ count: number; last: Token | undefined; content: string }
|
||||
>();
|
||||
parser.inline.ruler2.before("streaming_emphasis", "prose_before_completion", (state) => {
|
||||
counts.set(state, {
|
||||
count: state.tokens.length,
|
||||
last: state.tokens.at(-1),
|
||||
content: state.tokens.at(-1)?.content ?? "",
|
||||
});
|
||||
return true;
|
||||
});
|
||||
parser.inline.ruler2.after("streaming_emphasis", "prose_after_completion", (state) => {
|
||||
const before = counts.get(state)!;
|
||||
const trace: InlineTrace = state.env[TRACE];
|
||||
trace.suffix = state.tokens
|
||||
.slice(before.count)
|
||||
.map((token) => token.content)
|
||||
.join("");
|
||||
trace.trim = before.content.length - (before.last?.content.length ?? 0);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
parser.core.ruler.after("inline", "prose_inline_traces", (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== "inline" || !token.children) continue;
|
||||
|
||||
@@ -1998,7 +1998,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return (
|
||||
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
|
||||
<MarkdownProse
|
||||
streaming={phase === "streaming"}
|
||||
kind={markdownKind}
|
||||
text={revealedMessage}
|
||||
sourceBlocks={markdownProseBlocks ?? [message]}
|
||||
|
||||
Reference in New Issue
Block a user