Skip to content

Commit 511f1a0

Browse files
committed
feat: add DocumentSegment for inline HTML/SVG/Mermaid rendering
Agent responses containing full documents (HTML reports, SVG diagrams, Mermaid charts) were previously truncated to 4000 chars server-side, then stripped of all HTML tags by rehype-sanitize, leaving only raw text. This three-layer failure made rich documents unreadable. Add a general DocumentSegment type to the artifact pipeline: - extractDocumentSpans(): two-pass detection — fenced code blocks with document languages (html/svg/mermaid, ≥500 chars) and inline HTML documents (≥1000 chars), including truncated streams missing </html> - Server sanitizer: raise text limit to 32K for document-containing events, with HTML-safe truncation at tag boundaries - InlineArtifactCard: new InlineDocumentCard with 240px sandboxed iframe preview in the timeline - ArtifactView: new DocumentFrame for full-height left-panel preview - Mermaid content wrapped in an HTML shell with CDN mermaid@11 13 new test cases covering extraction, resolution, and edge cases. Change-Id: I76108b0e606938fc5f3cbc34d7f78d6b52dadcd8 Co-developed-by: Qoder CLI <noreply@qoder.com>
1 parent fb9e73b commit 511f1a0

6 files changed

Lines changed: 547 additions & 12 deletions

File tree

apps/webui/src/app/desktop.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5229,6 +5229,60 @@ dialog.case-modal-overlay {
52295229
color: #b91c1c;
52305230
}
52315231

5232+
/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */
5233+
5234+
.inline-document-card {
5235+
border-radius: 10px;
5236+
overflow: hidden;
5237+
border: 1px solid rgba(0, 0, 0, 0.08);
5238+
background: #fff;
5239+
max-width: 420px;
5240+
}
5241+
5242+
.inline-document-bar {
5243+
display: flex;
5244+
align-items: center;
5245+
gap: 6px;
5246+
padding: 8px 12px;
5247+
font-size: 13px;
5248+
color: #374151;
5249+
background: #f9fafb;
5250+
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
5251+
}
5252+
5253+
.inline-document-title {
5254+
flex: 1;
5255+
overflow: hidden;
5256+
text-overflow: ellipsis;
5257+
white-space: nowrap;
5258+
}
5259+
5260+
.inline-document-btn {
5261+
display: inline-flex;
5262+
align-items: center;
5263+
gap: 4px;
5264+
font-size: 12px;
5265+
color: #6b7280;
5266+
background: none;
5267+
border: none;
5268+
cursor: pointer;
5269+
padding: 2px 4px;
5270+
border-radius: 4px;
5271+
}
5272+
5273+
.inline-document-btn:hover {
5274+
color: #2563eb;
5275+
background: rgba(37, 99, 235, 0.06);
5276+
}
5277+
5278+
.inline-document-frame {
5279+
display: block;
5280+
width: 100%;
5281+
height: 240px;
5282+
border: 0;
5283+
pointer-events: none;
5284+
}
5285+
52325286
/* ── Lightbox (fullscreen media preview) ── */
52335287

52345288
.lightbox-overlay {

apps/webui/src/components/ArtifactView.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
type Artifact,
1010
type ArtifactSegment,
1111
type DeliveredFile,
12+
type DocumentSegment,
13+
documentTypeLabel,
1214
preferInlineMarkdownPreview,
15+
resolveDocumentContent,
1316
} from "@/lib/view/artifact";
1417

1518
interface ArtifactViewProps {
@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
183186
);
184187
}
185188

189+
function DocumentFrame({ segment }: { segment: DocumentSegment }) {
190+
const title = segment.title ?? documentTypeLabel(segment.mimeType);
191+
const srcDoc = resolveDocumentContent(segment);
192+
const openNew = (event: MouseEvent) => {
193+
event.preventDefault();
194+
void openHtmlArtifactInNewWindow("", srcDoc);
195+
};
196+
return (
197+
<div className="artifact-frame-wrap">
198+
<div className="artifact-frame-bar">
199+
<span className="artifact-frame-url" title={title}>
200+
{title}
201+
</span>
202+
<button type="button" className="artifact-frame-btn" onClick={openNew}>
203+
<ExternalLink size={14} />
204+
新窗口打开
205+
</button>
206+
</div>
207+
<iframe
208+
className="artifact-frame"
209+
srcDoc={srcDoc}
210+
title={title}
211+
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
212+
/>
213+
</div>
214+
);
215+
}
216+
186217
function FileCard({ artifact }: { artifact: Artifact }) {
187218
const access = useArtifactAccess();
188219
const fileName = artifactDisplayName(artifact.url, artifact.title);
@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
291322
return `artifact:${segment.artifact.url}`;
292323
case "delivered_file":
293324
return `delivered:${segment.file.file_id}`;
325+
case "document":
326+
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
294327
}
295328
}
296329

@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
323356
}
324357
case "delivered_file":
325358
return <DeliveredFileCard file={segment.file} />;
359+
case "document":
360+
return <DocumentFrame segment={segment} />;
326361
}
327362
}
328363

apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
1+
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
22
import { type MouseEvent, useState } from "react";
33
import { useArtifactAccess } from "@/lib/artifact-access-context";
44
import { artifactDisplayName } from "@/lib/artifact-file-name";
55
import { getFileDownloadUrl } from "@/lib/domain/file-api";
6-
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
6+
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
7+
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
8+
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
79
import { Lightbox } from "../Lightbox";
810

911
function formatBytes(bytes?: number): string {
@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
154156
);
155157
}
156158

159+
function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
160+
const title = segment.title ?? documentTypeLabel(segment.mimeType);
161+
const srcDoc = resolveDocumentContent(segment);
162+
const openNew = (e: MouseEvent) => {
163+
e.preventDefault();
164+
void openHtmlArtifactInNewWindow("", srcDoc);
165+
};
166+
167+
return (
168+
<div className="inline-document-card">
169+
<div className="inline-document-bar">
170+
<Code size={14} />
171+
<span className="inline-document-title">{title}</span>
172+
<button type="button" className="inline-document-btn" onClick={openNew}>
173+
<ExternalLink size={13} />
174+
新窗口
175+
</button>
176+
</div>
177+
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
178+
</div>
179+
);
180+
}
181+
157182
// ---------------------------------------------------------------------------
158183
// 主组件
159184
// ---------------------------------------------------------------------------
@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
213238
}
214239
case "delivered_file":
215240
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
241+
case "document":
242+
return (
243+
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
244+
);
216245
case "text":
217246
// 纯文本已在 assistant 消息气泡中渲染,不再重复
218247
return null;

apps/webui/src/lib/view/artifact.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
33
import {
44
classifyUrl,
55
collectDeliveredFiles,
6+
documentTypeLabel,
67
extractArtifacts,
78
lastAssistantText,
89
preferInlineMarkdownPreview,
10+
resolveDocumentContent,
911
} from "./artifact";
1012

1113
function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
311313
).toEqual([]);
312314
});
313315
});
316+
317+
// ---------------------------------------------------------------------------
318+
// Document segment extraction
319+
// ---------------------------------------------------------------------------
320+
321+
/** Generate a realistic HTML document string of a given approximate length. */
322+
function makeHtmlDocument(minChars = 1200): string {
323+
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
324+
Math.ceil(minChars / 60),
325+
)}</p>`;
326+
return `<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="UTF-8"><title>Report</title></head>\n<body>\n${body}\n</body>\n</html>`;
327+
}
328+
329+
/** Generate a Mermaid diagram of a given approximate length. */
330+
function makeMermaidDiagram(minChars = 600): string {
331+
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
332+
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
333+
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
334+
}
335+
336+
describe("document extraction", () => {
337+
test("inline HTML document is extracted as a document segment", () => {
338+
const html = makeHtmlDocument();
339+
const input = `以下是报告:\n\n${html}`;
340+
const { segments } = extractArtifacts(input);
341+
const docs = segments.filter((s) => s.type === "document");
342+
expect(docs).toHaveLength(1);
343+
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
344+
// The preamble text should survive as a text segment.
345+
const texts = segments.filter((s) => s.type === "text");
346+
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
347+
});
348+
349+
test("inline HTML document title is extracted from <title>", () => {
350+
const html = makeHtmlDocument();
351+
const input = html;
352+
const { segments } = extractArtifacts(input);
353+
const doc = segments.find((s) => s.type === "document");
354+
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
355+
});
356+
357+
test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
358+
const html = makeHtmlDocument().replace(
359+
"</head>",
360+
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet"></head>',
361+
);
362+
const { segments } = extractArtifacts(html);
363+
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
364+
expect(artifacts).toHaveLength(0);
365+
});
366+
367+
test("short fenced HTML block stays as text (below threshold)", () => {
368+
const input = [
369+
"### 交付文件:`index.html`",
370+
"",
371+
"```html",
372+
"<!DOCTYPE html>",
373+
'<link rel="preconnect" href="https://fonts.googleapis.com">',
374+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
375+
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
376+
"```",
377+
"",
378+
"预览:https://preview.example.com/page",
379+
].join("\n");
380+
const { segments } = extractArtifacts(input);
381+
// The fenced block is too short to be a document — stays as text.
382+
expect(segments.some((s) => s.type === "text" && s.content.includes("fonts.googleapis.com"))).toBe(true);
383+
expect(segments.every((s) => s.type !== "document")).toBe(true);
384+
});
385+
386+
test("large fenced HTML block is extracted as a document segment", () => {
387+
const htmlContent = makeHtmlDocument(1000);
388+
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
389+
const { segments } = extractArtifacts(input);
390+
const docs = segments.filter((s) => s.type === "document");
391+
expect(docs).toHaveLength(1);
392+
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
393+
// The preamble and postamble should survive as text segments.
394+
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
395+
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
396+
});
397+
398+
test("fenced mermaid block is extracted as a document segment", () => {
399+
const mermaid = makeMermaidDiagram(600);
400+
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
401+
const { segments } = extractArtifacts(input);
402+
const docs = segments.filter((s) => s.type === "document");
403+
expect(docs).toHaveLength(1);
404+
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
405+
});
406+
407+
test("truncated inline HTML (no </html>) is still extracted as document", () => {
408+
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
409+
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
410+
const { segments } = extractArtifacts(truncatedHtml);
411+
const docs = segments.filter((s) => s.type === "document");
412+
expect(docs).toHaveLength(1);
413+
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
414+
});
415+
416+
test("truncated HTML with short preamble is extracted as document", () => {
417+
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
418+
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
419+
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
420+
const input = preamble + truncatedHtml;
421+
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
422+
const { segments } = extractArtifacts(input);
423+
const docs = segments.filter((s) => s.type === "document");
424+
expect(docs).toHaveLength(1);
425+
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
426+
// Preamble survives as text.
427+
const texts = segments.filter((s) => s.type === "text");
428+
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
429+
});
430+
431+
test("preferInlineMarkdownPreview returns false when document segments exist", () => {
432+
const html = makeHtmlDocument();
433+
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
434+
const { segments } = extractArtifacts(input);
435+
expect(preferInlineMarkdownPreview(segments)).toBe(false);
436+
});
437+
});
438+
439+
describe("resolveDocumentContent", () => {
440+
test("returns HTML content as-is", () => {
441+
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
442+
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
443+
expect(result).toBe(html);
444+
});
445+
446+
test("returns SVG content as-is", () => {
447+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
448+
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
449+
expect(result).toBe(svg);
450+
});
451+
452+
test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
453+
const mermaid = "graph TD\n A --> B";
454+
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
455+
expect(result).toContain("mermaid.min.js");
456+
expect(result).toContain('<pre class="mermaid">');
457+
expect(result).toContain("graph TD");
458+
expect(result).toContain("mermaid.initialize");
459+
});
460+
});
461+
462+
describe("documentTypeLabel", () => {
463+
test("returns correct labels", () => {
464+
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
465+
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
466+
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
467+
});
468+
});

0 commit comments

Comments
 (0)