diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e9f35fc57..886fb4f33 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing"; @@ -1917,6 +1918,7 @@ type CanvasTypewriterTextProps = { className?: string; renderMarkdown?: boolean; preserveMarkdownTables?: boolean; + isMarkdownStreaming?: boolean; enablePdfDownload?: boolean; pdfFileName?: string; pdfSiteLogo?: string | null; @@ -2357,20 +2359,6 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[] return ranges; } -function advanceTypewriterCursor( - contentLength: number, - targetCursor: number, - tableRanges: MarkdownTableBlockRange[] -) { - const clampedCursor = Math.min(contentLength, targetCursor); - for (const range of tableRanges) { - if (clampedCursor >= range.start && clampedCursor < range.end) { - return Math.min(contentLength, range.end); - } - } - return clampedCursor; -} - function CanvasTypewriterCursor() { return ( { - cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges); + cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges); if (cursor >= content.length) { setDisplayText(content); window.clearInterval(timer); @@ -2439,7 +2431,7 @@ function CanvasTypewriterText({ onActiveChange?.(typingKey, false); } }; - }, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]); + }, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]); const showCursor = enabled && Boolean(content) && displayText.length < content.length; @@ -2448,6 +2440,7 @@ function CanvasTypewriterText({
{text} @@ -16965,6 +16963,7 @@ export function CanvasChatPanel({ className="break-words [overflow-wrap:anywhere]" renderMarkdown preserveMarkdownTables={shouldTypeClawAssistantMessage} + isMarkdownStreaming={isMessageMarkdownStreaming} enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis} pdfFileName={`Claw分析报告-${message.id}`} pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null} @@ -16987,6 +16986,7 @@ export function CanvasChatPanel({ className="break-words [overflow-wrap:anywhere]" renderMarkdown preserveMarkdownTables={shouldTypeClawAssistantMessage} + isMarkdownStreaming={isMessageMarkdownStreaming} enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText} pdfFileName={`Claw分析报告-${message.id}`} pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null} diff --git a/components/elements/response.tsx b/components/elements/response.tsx index a1daf8b2e..932d24c9e 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -17,6 +17,7 @@ import { import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments"; +import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming"; import { Collapsible, CollapsibleContent, @@ -27,6 +28,7 @@ import { cn } from "@/lib/utils"; type ResponseProps = ComponentProps & { collapsibleImageAnalysis?: boolean; + isMarkdownStreaming?: boolean; enablePdfDownload?: boolean; pdfFileName?: string; pdfSiteLogo?: string | null; @@ -761,6 +763,7 @@ function ResponseReportTableDownloadButton({ export const Response = ({ className, collapsibleImageAnalysis = false, + isMarkdownStreaming = false, enablePdfDownload = false, pdfFileName, pdfSiteLogo, @@ -1025,7 +1028,7 @@ export const Response = ({ const rawText = typeof props.children === "string" - ? normalizeMarkdownContent(props.children) + ? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming)) : null; const shouldShowPdfDownload = Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText)); @@ -1055,7 +1058,7 @@ export const Response = ({ ); }; - if (rawText) { + if (rawText !== null) { const analysisSection = collapsibleImageAnalysis ? extractImageAnalysisSection(rawText) : null; diff --git a/lib/canvas/markdown-image-streaming.ts b/lib/canvas/markdown-image-streaming.ts new file mode 100644 index 000000000..6009e11a4 --- /dev/null +++ b/lib/canvas/markdown-image-streaming.ts @@ -0,0 +1,115 @@ +/** A half-open span containing a complete Markdown image or linked 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 { + if (start >= text.length) return { kind: "pending" }; + if (text[start] !== "(") return { kind: "invalid" }; + let depth = 1; + for (let i = start + 1; i < text.length; i += 1) { + const char = text[i]; + if (char === "\\") { + if (i + 1 >= text.length) return { kind: "pending" }; + i += 1; + continue; + } + if (/\s/.test(char)) return { kind: "invalid" }; + if (char === "(") depth += 1; + if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 }; + } + return { kind: "pending" }; +} + +function readImage(text: string, start: number, wrapped: boolean): Parsed { + const imageStart = start + (wrapped ? 1 : 0); + if (imageStart + 1 >= text.length) return { kind: "pending" }; + if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" }; + let depth = 1; + for (let i = imageStart + 2; i < text.length; i += 1) { + const char = text[i]; + 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; + const inner = readDestination(text, i + 1); + if (inner.kind !== "complete" || !wrapped) return inner; + if (inner.end >= text.length) return { kind: "pending" }; + if (text[inner.end] !== "]") return { kind: "invalid" }; + return readDestination(text, inner.end + 1); + } + return { kind: "pending" }; +} + +/** Locate completed image syntax and a potentially incomplete trailing image. + * Code spans/fences and escaped markers remain normal text. */ +export function scanMarkdownImageStreaming(text: string): { + ranges: MarkdownImageRange[]; + pendingStart: number | null; +} { + const ranges: MarkdownImageRange[] = []; + let cursor = 0; + while (cursor < text.length) { + if (text[cursor] === "\\") { cursor += 2; continue; } + if (text[cursor] === "`") { + let end = cursor + 1; + while (text[end] === "`") end += 1; + const marker = text.slice(cursor, end); + const close = text.indexOf(marker, end); + cursor = close < 0 ? text.length : close + marker.length; + continue; + } + if (cursor === 0 || text[cursor - 1] === "\n") { + const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/); + if (fence) { + const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`); + let line = cursor + fence[0].length; + while (line < text.length) { + const next = text.indexOf("\n", line); + const end = next < 0 ? text.length : next; + if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; } + line = next < 0 ? end : end + 1; + } + cursor = line; + continue; + } + } + 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); + 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; + } + return { ranges, pendingStart: null }; +} + +/** During active delivery, withhold only unfinished image syntax at the tail. */ +export function stableStreamingMarkdown(text: string, streaming: boolean): string { + if (!streaming) return text; + const { pendingStart } = scanMarkdownImageStreaming(text); + return pendingStart === null ? text : text.slice(0, pendingStart); +} + +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. + for (let changed = true; changed;) { + changed = false; + for (const range of ranges) { + if (position > range.start && position < range.end) { + position = Math.min(length, range.end); + changed = true; + } + } + } + return position; +} diff --git a/tests/integration/markdown-image-streaming.test.ts b/tests/integration/markdown-image-streaming.test.ts new file mode 100644 index 000000000..a34d4a9d5 --- /dev/null +++ b/tests/integration/markdown-image-streaming.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { advancePastAtomicRanges, scanMarkdownImageStreaming, stableStreamingMarkdown } from "../../lib/canvas/markdown-image-streaming"; +import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments"; + +const url = "https://scqny.jiansun.vip/ps-skill-api/results/job_test/01-%E6%A0%B7%E4%BE%8B.jpg"; +const wrapped = `[![01-样例.jpg](${url})](${url})`; +const other = `[![缩略图](${url})](https://example.com/original.jpg)`; + +test("every live network prefix withholds the entire unfinished linked image", () => { + const before = "## 生成图\n"; + for (let n = 1; n < wrapped.length; n += 1) { + assert.equal(stableStreamingMarkdown(before + wrapped.slice(0, n), true), before, `prefix ${n}`); + } + assert.equal(stableStreamingMarkdown(before + wrapped, true), before + wrapped); + // An interrupted / completed response keeps malformed original text visible. + for (const suffix of ["[", "[!", "[![name](https://example.com/a.jpg)]("]) { + assert.equal(stableStreamingMarkdown(before + suffix, false), before + suffix); + } +}); + +test("every synthetic typewriter cursor exposes complete image markup or none", () => { + const content = `生成图\n${wrapped}\n${other}\n可编辑文件`; + const ranges = scanMarkdownImageStreaming(content).ranges; + assert.equal(ranges.length, 2); + let cursor = 0; + while (cursor < content.length) { + cursor = advancePastAtomicRanges(content.length, cursor + 1, ranges); + const visible = content.slice(0, cursor); + for (const range of ranges) { + assert.ok(cursor <= range.start || cursor >= range.end, `split at ${cursor}`); + } + if (cursor >= ranges[0].end) { + assert.ok(splitMarkdownImages(visible).some((item) => item.type === "image")); + } + } + assert.equal(cursor, content.length); + assert.deepEqual(splitMarkdownImages(other), [{ type: "text", value: other }]); +}); + +test("handles ordinary images, URL brackets, escaped text and code safely", () => { + const simple = "![图](https://example.com/a_(1).jpg)"; + for (let n = 1; n < simple.length; n += 1) { + assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`); + } + const literal = "`![code](https://example.com/a` and \\![literal] and [ordinary](https://example.com)"; + assert.equal(stableStreamingMarkdown(literal, true), literal); + const fence = "~~~md\n[![sample](https://example.com\n~~~\n"; + assert.equal(stableStreamingMarkdown(fence, true), fence); + assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]); + const invalid = "![bad](https://example.com/a b) and later text"; + assert.equal(stableStreamingMarkdown(invalid, true), invalid); +}); + +test("does not change normal links or security-sensitive complete markup", () => { + const values = ["[普通链接](https://example.com/a)", "[![x](https://example.com/x.jpg)](javascript:alert(1))", other]; + for (const value of values) assert.equal(stableStreamingMarkdown(value, true), value); +}); + +test("coalesces overlapping typewriter table and image ranges without looping", () => { + const ranges = [{ start: 0, end: 20 }, { start: 10, end: 35 }, { start: 35, end: 40 }]; + assert.equal(advancePastAtomicRanges(50, 1, ranges), 35); + assert.equal(advancePastAtomicRanges(50, 36, ranges), 40); + assert.equal(advancePastAtomicRanges(50, 50, ranges), 50); +});