Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/webui/src/app/desktop.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -5229,6 +5229,59 @@ dialog.case-modal-overlay {
color: #b91c1c;
}

/* ── Inline Document Card (HTML / SVG / Mermaid preview) ── */

.inline-document-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
max-width: 420px;
}

.inline-document-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
background: #f9fafb;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}

.inline-document-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.inline-document-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}

.inline-document-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}

.inline-document-frame {
display: block;
width: 100%;
height: 240px;
border: 0;
}

/* ── Lightbox (fullscreen media preview) ── */

.lightbox-overlay {
Expand Down
35 changes: 35 additions & 0 deletions apps/webui/src/components/ArtifactView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
type DocumentSegment,
documentTypeLabel,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "@/lib/view/artifact";

interface ArtifactViewProps {
Expand DownExpand Up@@ -183,6 +186,34 @@ function WebFrame({ artifact }: { artifact: Artifact }) {
);
}

function DocumentFrame({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (event: MouseEvent) => {
event.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};
return (
<div className="artifact-frame-wrap">
<div className="artifact-frame-bar">
<span className="artifact-frame-url" title={title}>
{title}
</span>
<button type="button" className="artifact-frame-btn" onClick={openNew}>
<ExternalLink size={14} />
新窗口打开
</button>
</div>
<iframe
className="artifact-frame"
srcDoc={srcDoc}
title={title}
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
/>
</div>
);
}

function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
Expand DownExpand Up@@ -291,6 +322,8 @@ function segmentKey(segment: ArtifactSegment): string {
return `artifact:${segment.artifact.url}`;
case "delivered_file":
return `delivered:${segment.file.file_id}`;
case "document":
return `document:${segment.mimeType}:${segment.content.slice(0, 128)}`;
}
}

Expand DownExpand Up@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return <DeliveredFileCard file={segment.file} />;
case "document":
return <DocumentFrame segment={segment} />;
}
}

Expand Down
33 changes: 31 additions & 2 deletions apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { Code, Download, ExternalLink, FileText, Loader2, Play } from "lucide-react";
import { type MouseEvent, useState } from "react";
import { useArtifactAccess } from "@/lib/artifact-access-context";
import { artifactDisplayName } from "@/lib/artifact-file-name";
import { getFileDownloadUrl } from "@/lib/domain/file-api";
import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact";
import { openHtmlArtifactInNewWindow } from "@/lib/hooks/useHtmlArtifactPreview";
import type { Artifact, ArtifactSegment, DeliveredFile, DocumentSegment } from "@/lib/view/artifact";
import { documentTypeLabel, resolveDocumentContent } from "@/lib/view/artifact";
import { Lightbox } from "../Lightbox";

function formatBytes(bytes?: number): string {
Expand DownExpand Up@@ -154,6 +156,29 @@ function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) {
);
}

function InlineDocumentCard({ segment }: { segment: DocumentSegment }) {
const title = segment.title ?? documentTypeLabel(segment.mimeType);
const srcDoc = resolveDocumentContent(segment);
const openNew = (e: MouseEvent) => {
e.preventDefault();
void openHtmlArtifactInNewWindow("", srcDoc);
};

return (
<div className="inline-document-card">
<div className="inline-document-bar">
<Code size={14} />
<span className="inline-document-title">{title}</span>
<button type="button" className="inline-document-btn" onClick={openNew}>
<ExternalLink size={13} />
新窗口
</button>
</div>
<iframe className="inline-document-frame" srcDoc={srcDoc} title={title} sandbox="allow-scripts" />
</div>
);
}

// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return <InlineDeliveredFileCard key={segment.file.file_id} file={segment.file} />;
case "document":
return (
<InlineDocumentCard key={`doc:${segment.mimeType}:${segment.content.slice(0, 64)}`} segment={segment} />
);
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
Expand Down
161 changes: 158 additions & 3 deletions apps/webui/src/lib/view/artifact.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
resolveDocumentContent,
} from "./artifact";

function deliveredEvent(artifact: Record<string, unknown>): SessionEvent {
Expand DownExpand Up@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">',
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
Expand DownExpand Up@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Document segment extraction
// ---------------------------------------------------------------------------

/** Generate a realistic HTML document string of a given approximate length. */
function makeHtmlDocument(minChars = 1200): string {
const body = `<h1>Report Title</h1>\n<p>${"This is a paragraph of report content with data-driven insights. ".repeat(
Math.ceil(minChars / 60),
)}</p>`;
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>`;
}

/** Generate a Mermaid diagram of a given approximate length. */
function makeMermaidDiagram(minChars = 600): string {
const nodes = Array.from({ length: Math.ceil(minChars / 50) }, (_, i) => ` N${i}["Node ${i} description text"]`);
const edges = Array.from({ length: nodes.length - 1 }, (_, i) => ` N${i} --> N${i + 1}`);
return `graph TD\n${nodes.join("\n")}\n${edges.join("\n")}`;
}

describe("document extraction", () => {
test("inline HTML document is extracted as a document segment", () => {
const html = makeHtmlDocument();
const input = `以下是报告:\n\n${html}`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble text should survive as a text segment.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是报告"))).toBe(true);
});

test("inline HTML document title is extracted from <title>", () => {
const html = makeHtmlDocument();
const input = html;
const { segments } = extractArtifacts(input);
const doc = segments.find((s) => s.type === "document");
expect(doc?.type === "document" ? doc.title : undefined).toBe("Report");
});

test("URLs inside an extracted HTML document are NOT extracted as artifacts", () => {
const html = makeHtmlDocument().replace(
"</head>",
'<link href="https://cdn.example.test/styles.css" rel="stylesheet"></head>',
);
const { segments } = extractArtifacts(html);
const artifacts = segments.filter((s) => s.type === "artifact" || s.type === "images");
expect(artifacts).toHaveLength(0);
});

test("short fenced HTML block stays as text (below threshold)", () => {
const input = [
"### 交付文件:`index.html`",
"",
"```html",
"<!DOCTYPE html>",
'<link rel="preconnect" href="https://cdn.example.test">',
'<link rel="preconnect" href="https://assets.example.test" crossorigin>',
'<link href="https://cdn.example.test/styles.css" rel="stylesheet">',
"```",
"",
"预览:https://preview.example.com/page",
].join("\n");
const { segments } = extractArtifacts(input);
// The fenced block is too short to be a document — stays as text.
expect(segments.some((s) => s.type === "text" && s.content.includes("cdn.example.test"))).toBe(true);
expect(segments.every((s) => s.type !== "document")).toBe(true);
});

test("large fenced HTML block is extracted as a document segment", () => {
const htmlContent = makeHtmlDocument(1000);
const input = `Report:\n\n\`\`\`html\n${htmlContent}\n\`\`\`\n\nDone.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// The preamble and postamble should survive as text segments.
expect(segments.some((s) => s.type === "text" && s.content.includes("Report:"))).toBe(true);
expect(segments.some((s) => s.type === "text" && s.content.includes("Done."))).toBe(true);
});

test("fenced mermaid block is extracted as a document segment", () => {
const mermaid = makeMermaidDiagram(600);
const input = `Diagram:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\`\n\nEnd.`;
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/mermaid" });
});

test("truncated inline HTML (no </html>) is still extracted as document", () => {
// Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${"<p>content</p>\n".repeat(80)}`;
const { segments } = extractArtifacts(truncatedHtml);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
});

test("truncated HTML with short preamble is extracted as document", () => {
// The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n";
const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${"<p>报告内容段落。</p>\n".repeat(100)}`;
const input = preamble + truncatedHtml;
expect(input).not.toMatch(/<\/html>/i); // no closing tag = truncated
const { segments } = extractArtifacts(input);
const docs = segments.filter((s) => s.type === "document");
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({ type: "document", mimeType: "text/html" });
// Preamble survives as text.
const texts = segments.filter((s) => s.type === "text");
expect(texts.some((s) => s.type === "text" && s.content.includes("以下是基于"))).toBe(true);
});

test("preferInlineMarkdownPreview returns false when document segments exist", () => {
const html = makeHtmlDocument();
const input = `![img](https://x.com/1.png)\n\nLong text content here for testing.\n\n${html}`;
const { segments } = extractArtifacts(input);
expect(preferInlineMarkdownPreview(segments)).toBe(false);
});
});

describe("resolveDocumentContent", () => {
test("returns HTML content as-is", () => {
const html = "<!DOCTYPE html><html><body>Hello</body></html>";
const result = resolveDocumentContent({ type: "document", content: html, mimeType: "text/html" });
expect(result).toBe(html);
});

test("returns SVG content as-is", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>';
const result = resolveDocumentContent({ type: "document", content: svg, mimeType: "image/svg+xml" });
expect(result).toBe(svg);
});

test("wraps Mermaid content in an HTML shell with mermaid.js", () => {
const mermaid = "graph TD\n A --> B";
const result = resolveDocumentContent({ type: "document", content: mermaid, mimeType: "text/mermaid" });
expect(result).toContain("mermaid.min.js");
expect(result).toContain('<pre class="mermaid">');
expect(result).toContain("graph TD");
expect(result).toContain("mermaid.initialize");
});
});

describe("documentTypeLabel", () => {
test("returns correct labels", () => {
expect(documentTypeLabel("text/html")).toBe("HTML 文档");
expect(documentTypeLabel("image/svg+xml")).toBe("SVG 图形");
expect(documentTypeLabel("text/mermaid")).toBe("Mermaid 图表");
});
});
Loading