mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(app): preserve markdown semantics and render limits in prose runs
This commit is contained in:
@@ -124,6 +124,43 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
|
||||
}
|
||||
|
||||
describe("native prose runs", () => {
|
||||
it("starts another run before combined paragraphs exceed the render cap", () => {
|
||||
const first = "a".repeat(20_000);
|
||||
const second = "b".repeat(20_000);
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
for (const text of [first, "\n\n" + second.slice(0, 10_000), second.slice(10_000)]) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const live = present({ ...presentationOptions, ...state, transform: undefined });
|
||||
expect([...live.tail, ...live.head]).toMatchObject([
|
||||
{ id: "message-1:run:0", text: first },
|
||||
{ id: "message-1:run:1", text: second },
|
||||
]);
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(`${first}\n\n${second}`), timestamp: new Date(1000) },
|
||||
]);
|
||||
expect(
|
||||
present({ ...presentationOptions, tail: source, head: [], transform: undefined }).tail,
|
||||
).toMatchObject([{ text: first }, { text: second }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"![Screenshot][pic]\n\n[pic]: shots/app.png",
|
||||
"- parent\n - before  after\n - next",
|
||||
])("keeps image context in Paseo's block renderer: %s", (text) => {
|
||||
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([{ text, markdownKind: "block" }]);
|
||||
expect(result.tail).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("splits inline workspace images out of prose without mistaking code for images", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
|
||||
import { groupMarkdownProse } from "@/components/markdown-prose/runs";
|
||||
import { groupMarkdownProse } from "@/components/markdown-prose";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// TypeScript's platform-neutral entry; Metro chooses the native or web implementation.
|
||||
export { MarkdownProse } from "./index.web";
|
||||
export { MarkdownProse } from "./renderer";
|
||||
export { groupMarkdownProse } from "./runs";
|
||||
|
||||
@@ -9,6 +9,8 @@ describe("native prose dialect", () => {
|
||||
it.each([
|
||||
"Literal & and ©",
|
||||
"# #",
|
||||
"Setext heading\n==============",
|
||||
'A [titled link](https://example.com "A title")',
|
||||
"- first\n\tsecond",
|
||||
"Blank ` ` code and ` normal ` code",
|
||||
"# Heading\n\n- **First**\n- Second\n\n~~Deleted~~",
|
||||
@@ -77,3 +79,25 @@ it("uppercases heading-six text without changing inline code or link destination
|
||||
'<h6>SMALL <strong>BOLD</strong> <code>lower.ts</code> <a href="https://example.com/lower">LINK</a></h6>\n',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves inline semantics and leaves reference definitions untouched (streaming=%s)",
|
||||
(streaming) => {
|
||||
const text =
|
||||
"[foo][ref] <https://example.org> & \\* **bold** \n[linked `code`](https://example.net)\n\n[ref]: https://example.com";
|
||||
const prepared = prepareProse(text, noFiles, streaming);
|
||||
expect(prepared).toContain("[ref]: https://example.com");
|
||||
expect(parser.render(prepared)).toBe(parser.render(text));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["- [ ] Task", "- [x] Done", "1. [X] Done", "- parent\n - [ ] Nested task"])(
|
||||
"keeps task markers literal through streaming and settlement: %s",
|
||||
(text) => {
|
||||
const settled = prepareProse(text, noFiles);
|
||||
const streaming = prepareProse(text, noFiles, true);
|
||||
expect(settled).toMatch(/\\\[/);
|
||||
expect(streaming).toBe(settled);
|
||||
expect(parser.render(settled)).toBe(parser.render(text));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ function escapeText(text: string): string {
|
||||
}
|
||||
|
||||
function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean): string {
|
||||
const links: string[] = [];
|
||||
const links: { href: string; title: string | null }[] = [];
|
||||
return tokens
|
||||
.map((token) => {
|
||||
switch (token.type) {
|
||||
@@ -27,10 +27,15 @@ function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean
|
||||
case "hardbreak":
|
||||
return " \n";
|
||||
case "link_open":
|
||||
links.push(token.attrGet("href") ?? "");
|
||||
links.push({ href: token.attrGet("href") ?? "", title: token.attrGet("title") });
|
||||
return "[";
|
||||
case "link_close": {
|
||||
return `](<${links.pop() ?? ""}>)`;
|
||||
const link = links.pop()!;
|
||||
const title =
|
||||
link.title === null
|
||||
? ""
|
||||
: ` "${link.title.replace(/&/g, "&").replace(/"/g, """)}"`;
|
||||
return `](<${link.href}>${title})`;
|
||||
}
|
||||
case "code_inline": {
|
||||
const needsPadding =
|
||||
@@ -60,52 +65,22 @@ export function prepareProse(
|
||||
text: string,
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming = false,
|
||||
): string {
|
||||
text = prepareBlockInlines(text, canResolveCode, streaming);
|
||||
const state = new parser.inline.State(text, parser, {}, []);
|
||||
let prepared = "";
|
||||
while (state.pos < state.posMax) {
|
||||
const start = state.pos;
|
||||
parser.inline.skipToken(state);
|
||||
const source = text.slice(start, state.pos);
|
||||
// Escapes and entities already protect their contents from delimiter parsing.
|
||||
if (source.startsWith("\\") || source.startsWith("&")) {
|
||||
prepared += source;
|
||||
continue;
|
||||
}
|
||||
const tokens = parser.parseInline(source, {})[0]!.children!;
|
||||
const containsLinkOrCode = tokens.some(
|
||||
(token) => token.type === "code_inline" || token.type === "link_open",
|
||||
);
|
||||
prepared += containsLinkOrCode
|
||||
? renderInline(tokens, canResolveCode)
|
||||
: source.replace(/\|/g, "\\|");
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function prepareBlockInlines(
|
||||
text: string,
|
||||
canResolveCode: (text: string) => boolean,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
const tokens = (streaming ? streamingParser : parser).parse(text, {});
|
||||
const tail = tokens.findLast((token) => token.nesting !== -1);
|
||||
const lines = text.split("\n");
|
||||
for (let index = tokens.length - 1; index >= 0; index--) {
|
||||
const token = tokens[index]!;
|
||||
const uppercase = tokens[index - 1]?.type === "heading_open" && tokens[index - 1]?.tag === "h6";
|
||||
if (
|
||||
token.type !== "inline" ||
|
||||
!token.map ||
|
||||
!token.children ||
|
||||
!(uppercase || (streaming && token === tail))
|
||||
)
|
||||
continue;
|
||||
const [start, end] = token.map;
|
||||
if (token.type !== "inline" || !token.map || !token.children) continue;
|
||||
const [start] = token.map;
|
||||
const prefixes = token.content.split("\n").map((content, offset) => {
|
||||
const line = lines[start + offset]!;
|
||||
const position = line.indexOf(content);
|
||||
const heading = tokens[index - 1];
|
||||
const headingPrefix =
|
||||
heading?.type === "heading_open" && heading.markup.startsWith("#")
|
||||
? (line.match(/^(?:\s*>\s*)*\s*#{1,6}\s+/)?.[0].length ?? 0)
|
||||
: 0;
|
||||
const position = line.indexOf(content, headingPrefix);
|
||||
return position < 0 ? "" : line.slice(0, position);
|
||||
});
|
||||
if (uppercase) {
|
||||
@@ -119,7 +94,7 @@ function prepareBlockInlines(
|
||||
const replacement = rendered.map(
|
||||
(line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`,
|
||||
);
|
||||
lines.splice(start, end - start, ...replacement);
|
||||
lines.splice(start, token.content.split("\n").length, ...replacement);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
+11
-2
@@ -58,8 +58,17 @@ function proseStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
|
||||
marginBottom: styles.blockquote.marginVertical,
|
||||
},
|
||||
link: { color: styles.link.color, underline: false },
|
||||
code: { ...styles.code_inline, borderColor: "transparent" },
|
||||
strong: styles.strong,
|
||||
code: {
|
||||
// RCTFont does not resolve Fabric's ui-monospace alias. An empty family
|
||||
// selects the library's native system-monospace font on iOS.
|
||||
fontFamily:
|
||||
styles.code_inline.fontFamily === "ui-monospace" ? "" : styles.code_inline.fontFamily,
|
||||
fontSize: styles.code_inline.fontSize,
|
||||
color: styles.code_inline.color,
|
||||
backgroundColor: styles.code_inline.backgroundColor,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
strong: { fontWeight: "bold" },
|
||||
em: styles.em,
|
||||
strikethrough: { color: styles.s.color },
|
||||
thematicBreak: {
|
||||
@@ -0,0 +1 @@
|
||||
export { MarkdownProse } from "./renderer.web";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { AssistantMessageItem } from "@/types/stream";
|
||||
|
||||
@@ -7,7 +8,8 @@ const partsByBlock = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>()
|
||||
function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const cached = partsByBlock.get(block);
|
||||
if (cached) return cached;
|
||||
const tokens = parser.parse(block.text, {});
|
||||
const environment: { references?: unknown } = {};
|
||||
const tokens = parser.parse(block.text, environment);
|
||||
const preserved = tokens.some(
|
||||
(token) =>
|
||||
["fence", "code_block", "table_open", "html_block"].includes(token.type) ||
|
||||
@@ -16,7 +18,14 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const isQuote = tokens.some((token) => token.type === "blockquote_open");
|
||||
const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image"));
|
||||
let parts: AssistantMessageItem[];
|
||||
if (preserved || (isQuote && hasImage)) {
|
||||
// Retain the container and its definitions when splitting would lose image context.
|
||||
if (
|
||||
preserved ||
|
||||
(hasImage &&
|
||||
(isQuote ||
|
||||
environment.references ||
|
||||
tokens.some((token) => token.type === "list_item_open")))
|
||||
) {
|
||||
parts = [{ ...block, markdownKind: "block" }];
|
||||
} else if (hasImage) {
|
||||
parts = splitParagraphImages(block);
|
||||
@@ -56,7 +65,11 @@ export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMes
|
||||
const rows: AssistantMessageItem[] = [];
|
||||
for (const block of blocks.flatMap(splitBlock)) {
|
||||
const previous = rows.at(-1);
|
||||
if (block.markdownKind === "prose" && previous?.markdownKind === "prose") {
|
||||
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}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user