Avoid partial image markup during streamed responses
This commit is contained in:
parent
e6f07ed2ce
commit
5a343b6392
@ -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 (
|
||||
<span
|
||||
@ -2387,6 +2375,7 @@ function CanvasTypewriterText({
|
||||
className,
|
||||
renderMarkdown = false,
|
||||
preserveMarkdownTables = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -2413,12 +2402,15 @@ function CanvasTypewriterText({
|
||||
const tableRanges = preserveMarkdownTables
|
||||
? getMarkdownTableBlockRanges(content)
|
||||
: [];
|
||||
const atomicRanges = renderMarkdown
|
||||
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
|
||||
: tableRanges;
|
||||
activeRef.current = true;
|
||||
onActiveChange?.(typingKey, true);
|
||||
setDisplayText("");
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
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({
|
||||
<div className="w-full min-w-0">
|
||||
<Response
|
||||
className={className}
|
||||
isMarkdownStreaming={isMarkdownStreaming}
|
||||
enablePdfDownload={enablePdfDownload}
|
||||
pdfFileName={pdfFileName}
|
||||
pdfSiteLogo={pdfSiteLogo}
|
||||
@ -16577,6 +16570,10 @@ export function CanvasChatPanel({
|
||||
(isClawdMode || isClawdChatMessage(message));
|
||||
const isLatestClawAssistantMessage =
|
||||
message.id === latestClawAssistantMessageId;
|
||||
const isMessageMarkdownStreaming =
|
||||
message.role === "assistant" &&
|
||||
index === renderableVisibleMessages.length - 1 &&
|
||||
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
|
||||
const openClawArtifacts = isClawAssistantMessage
|
||||
? getOpenClawMessageArtifacts(message)
|
||||
: [];
|
||||
@ -16904,6 +16901,7 @@ export function CanvasChatPanel({
|
||||
<Response
|
||||
key={key}
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
>
|
||||
{text}
|
||||
</Response>
|
||||
@ -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}
|
||||
|
||||
@ -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<typeof Streamdown> & {
|
||||
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;
|
||||
|
||||
115
lib/canvas/markdown-image-streaming.ts
Normal file
115
lib/canvas/markdown-image-streaming.ts
Normal file
@ -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;
|
||||
}
|
||||
65
tests/integration/markdown-image-streaming.test.ts
Normal file
65
tests/integration/markdown-image-streaming.test.ts
Normal file
@ -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 = `[](${url})`;
|
||||
const other = `[](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 ["[", "[!", "[]("]) {
|
||||
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 = ".jpg)";
|
||||
for (let n = 1; n < simple.length; n += 1) {
|
||||
assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`);
|
||||
}
|
||||
const literal = "`";
|
||||
assert.equal(stableStreamingMarkdown(literal, true), literal);
|
||||
const fence = "~~~md\n[, fence);
|
||||
assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]);
|
||||
const invalid = " 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)", "[](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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user