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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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