From d9b06cf0b30c63fe4396901fe892a15748410189 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 2 Nov 2026 19:10:16 +0800 Subject: [PATCH] Keep streamed result file links intact --- lib/canvas/markdown-image-streaming.ts | 47 +++++++++++++++---- .../markdown-image-streaming.test.ts | 39 +++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/lib/canvas/markdown-image-streaming.ts b/lib/canvas/markdown-image-streaming.ts index 6009e11a4..2248fad68 100644 --- a/lib/canvas/markdown-image-streaming.ts +++ b/lib/canvas/markdown-image-streaming.ts @@ -1,11 +1,11 @@ -/** A half-open span containing a complete Markdown image or linked image. */ +/** A half-open span containing a complete Markdown link or image. */ export type MarkdownImageRange = { start: number; end: number }; type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" }; // This scanner recognizes syntax only; rendering and URL safety remain the // responsibility of the existing Markdown renderer and image splitter. -function readDestination(text: string, start: number): Parsed { +function readDestination(text: string, start: number, allowWhitespace = false): Parsed { if (start >= text.length) return { kind: "pending" }; if (text[start] !== "(") return { kind: "invalid" }; let depth = 1; @@ -16,7 +16,7 @@ function readDestination(text: string, start: number): Parsed { i += 1; continue; } - if (/\s/.test(char)) return { kind: "invalid" }; + if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" }; if (char === "(") depth += 1; if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 }; } @@ -47,7 +47,37 @@ function readImage(text: string, start: number, wrapped: boolean): Parsed { return { kind: "pending" }; } -/** Locate completed image syntax and a potentially incomplete trailing image. +function readLink(text: string, start: number): Parsed { + let depth = 1; + for (let i = start + 1; i < text.length; i += 1) { + const char = text[i]; + // An unfinished bracket on an earlier line must not hide later output. + if (char === "\n" || char === "\r") return { kind: "invalid" }; + if (char === "\\") { + if (i + 1 >= text.length) return { kind: "pending" }; + i += 1; + continue; + } + if (char === "[") depth += 1; + if (char !== "]" || --depth !== 0) continue; + if (text[i + 1] === "[") { + for (let j = i + 2; j < text.length; j += 1) { + if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" }; + if (text[j] === "\\") { + if (j + 1 >= text.length) return { kind: "pending" }; + j += 1; + continue; + } + if (text[j] === "]") return { kind: "complete", end: j + 1 }; + } + return { kind: "pending" }; + } + return readDestination(text, i + 1, true); + } + return { kind: "pending" }; +} + +/** Locate complete Markdown links/images and an incomplete trailing construct. * Code spans/fences and escaped markers remain normal text. */ export function scanMarkdownImageStreaming(text: string): { ranges: MarkdownImageRange[]; @@ -83,8 +113,9 @@ export function scanMarkdownImageStreaming(text: string): { const char = text[cursor]; if (char !== "[" && char !== "!") { cursor += 1; continue; } const wrapped = char === "["; - if (wrapped && cursor + 1 < text.length && text[cursor + 1] !== "!") { cursor += 1; continue; } - const parsed = readImage(text, cursor, wrapped); + const parsed = wrapped && text[cursor + 1] !== "!" + ? readLink(text, cursor) + : readImage(text, cursor, wrapped); if (parsed.kind === "pending") return { ranges, pendingStart: cursor }; if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; } else cursor += 1; @@ -92,7 +123,7 @@ export function scanMarkdownImageStreaming(text: string): { return { ranges, pendingStart: null }; } -/** During active delivery, withhold only unfinished image syntax at the tail. */ +/** During active delivery, withhold only unfinished link/image syntax at the tail. */ export function stableStreamingMarkdown(text: string, streaming: boolean): string { if (!streaming) return text; const { pendingStart } = scanMarkdownImageStreaming(text); @@ -101,7 +132,7 @@ export function stableStreamingMarkdown(text: string, streaming: boolean): strin export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) { let position = Math.min(length, target); - // Overlapping table/image ranges can require more than one jump. + // Overlapping table/link ranges can require more than one jump. for (let changed = true; changed;) { changed = false; for (const range of ranges) { diff --git a/tests/integration/markdown-image-streaming.test.ts b/tests/integration/markdown-image-streaming.test.ts index a34d4a9d5..80d029d12 100644 --- a/tests/integration/markdown-image-streaming.test.ts +++ b/tests/integration/markdown-image-streaming.test.ts @@ -63,3 +63,42 @@ test("coalesces overlapping typewriter table and image ranges without looping", assert.equal(advancePastAtomicRanges(50, 36, ranges), 40); assert.equal(advancePastAtomicRanges(50, 50, ranges), 50); }); + +test("SKU result links are atomic across every typewriter cursor and live fragment", () => { + const links = [ + "[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)", + "[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)", + ]; + const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`; + const ranges = scanMarkdownImageStreaming(text).ranges; + assert.equal(ranges.length, 3); + for (const link of links) { + const start = text.indexOf(link); + for (let n = 1; n < link.length; n += 1) { + const prefix = text.slice(0, start + n); + assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`); + } + assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length)); + } + let cursor = 0; + while (cursor < text.length) { + cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges); + assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`); + } + assert.equal(cursor, text.length); +}); + +test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => { + for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) { + assert.equal(stableStreamingMarkdown(value, true), value); + assert.equal(stableStreamingMarkdown(value, false), value); + } + for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) { + assert.equal(stableStreamingMarkdown(partial, false), partial); + assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字"); + } + const titleLink = '[文档](https://example.com/a "标题")'; + assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]); + const unsafeLink = "[危险链接](javascript:alert(1))"; + assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink); +}); -- 2.47.2