diff --git a/apps/webui/src/app/desktop.css b/apps/webui/src/app/desktop.css
index 655569f..8231f73 100644
--- a/apps/webui/src/app/desktop.css
+++ b/apps/webui/src/app/desktop.css
@@ -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 {
diff --git a/apps/webui/src/components/ArtifactView.tsx b/apps/webui/src/components/ArtifactView.tsx
index ccca880..5bf8906 100644
--- a/apps/webui/src/components/ArtifactView.tsx
+++ b/apps/webui/src/components/ArtifactView.tsx
@@ -9,7 +9,10 @@ import {
type Artifact,
type ArtifactSegment,
type DeliveredFile,
+ type DocumentSegment,
+ documentTypeLabel,
preferInlineMarkdownPreview,
+ resolveDocumentContent,
} from "@/lib/view/artifact";
interface ArtifactViewProps {
@@ -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 (
+
+
+
+ {title}
+
+
+
+
+
+ );
+}
+
function FileCard({ artifact }: { artifact: Artifact }) {
const access = useArtifactAccess();
const fileName = artifactDisplayName(artifact.url, artifact.title);
@@ -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)}`;
}
}
@@ -323,6 +356,8 @@ function ArtifactBlock({ segment }: { segment: ArtifactSegment }) {
}
case "delivered_file":
return ;
+ case "document":
+ return ;
}
}
diff --git a/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx b/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
index d148a48..7d3e5f3 100644
--- a/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
+++ b/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx
@@ -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 {
@@ -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 (
+
+
+
+ {title}
+
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
@@ -213,6 +238,10 @@ export function InlineArtifactCard({ segments }: InlineArtifactCardProps) {
}
case "delivered_file":
return ;
+ case "document":
+ return (
+
+ );
case "text":
// 纯文本已在 assistant 消息气泡中渲染,不再重复
return null;
diff --git a/apps/webui/src/lib/view/artifact.test.ts b/apps/webui/src/lib/view/artifact.test.ts
index ce6d997..8d24180 100644
--- a/apps/webui/src/lib/view/artifact.test.ts
+++ b/apps/webui/src/lib/view/artifact.test.ts
@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
import {
classifyUrl,
collectDeliveredFiles,
+ documentTypeLabel,
extractArtifacts,
lastAssistantText,
preferInlineMarkdownPreview,
+ resolveDocumentContent,
} from "./artifact";
function deliveredEvent(artifact: Record): SessionEvent {
@@ -231,9 +233,9 @@ describe("extractArtifacts", () => {
"",
"```html",
"",
- '',
- '',
- '',
+ '',
+ '',
+ '',
"```",
"",
"预览:https://preview.example.com/page",
@@ -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 = `Report Title
\n${"This is a paragraph of report content with data-driven insights. ".repeat(
+ Math.ceil(minChars / 60),
+ )}
`;
+ return `\n\nReport\n\n${body}\n\n`;
+}
+
+/** 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 ", () => {
+ 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(
+ "",
+ '',
+ );
+ 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",
+ "",
+ '',
+ '',
+ '',
+ "```",
+ "",
+ "预览: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