Stabilize write artifact previews (#3172)

This commit is contained in:
AochenShen99
2026-05-23 16:56:14 +08:00
committed by GitHub
parent a64a39dbc0
commit 604fcbb9d2
7 changed files with 1022 additions and 46 deletions
+2
View File
@@ -1,3 +1,5 @@
pnpm-lock.yaml pnpm-lock.yaml
.omc/ .omc/
src/content/**/*.mdx src/content/**/*.mdx
playwright-report/
test-results/
@@ -8,7 +8,7 @@ import {
SquareArrowOutUpRightIcon, SquareArrowOutUpRightIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@@ -30,8 +30,16 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
createHtmlPreviewScrollKey,
getArtifactViewState,
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
} from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
@@ -44,6 +52,8 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
export function ArtifactFileDetail({ export function ArtifactFileDetail({
className, className,
filepath: filepathFromProps, filepath: filepathFromProps,
@@ -55,6 +65,7 @@ export function ArtifactFileDetail({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { thread, isMock } = useThread();
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@@ -83,6 +94,22 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => { const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown"; return language === "html" || language === "markdown";
}, [language]); }, [language]);
const toolResult = (() => {
if (!isWriteFile) {
return undefined;
}
const url = new URL(filepathFromProps);
const toolCallId = url.searchParams.get("tool_call_id");
if (!toolCallId) {
return undefined;
}
return findToolCallResult(toolCallId, thread.messages);
})();
const artifactViewState = getArtifactViewState({
filepath: filepathFromProps,
isSupportPreview,
toolResult,
});
const { content, url } = useArtifactContent({ const { content, url } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
@@ -90,17 +117,20 @@ export function ArtifactFileDetail({
}); });
const displayContent = content ?? ""; const displayContent = content ?? "";
const isWritingFile = isWriteFile && toolResult === undefined;
const visibleContent = useThrottledValue(
displayContent,
isWritingFile ? WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS : 0,
filepathFromProps,
);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">(
artifactViewState.initialViewMode,
);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const { isMock } = useThread();
useEffect(() => { useEffect(() => {
if (isSupportPreview) { setViewMode(artifactViewState.initialViewMode);
setViewMode("preview"); }, [artifactViewState.initialViewMode]);
} else {
setViewMode("code");
}
}, [isSupportPreview]);
const handleInstallSkill = useCallback(async () => { const handleInstallSkill = useCallback(async () => {
if (isInstalling) return; if (isInstalling) return;
@@ -149,7 +179,7 @@ export function ArtifactFileDetail({
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="flex min-w-0 grow items-center justify-center">
{isSupportPreview && ( {artifactViewState.canPreview && (
<ToggleGroup <ToggleGroup
className="mx-auto" className="mx-auto"
type="single" type="single"
@@ -209,7 +239,7 @@ export function ArtifactFileDetail({
disabled={!content} disabled={!content}
onClick={async () => { onClick={async () => {
try { try {
await navigator.clipboard.writeText(displayContent ?? ""); await navigator.clipboard.writeText(visibleContent ?? "");
toast.success(t.clipboard.copiedToClipboard); toast.success(t.clipboard.copiedToClipboard);
} catch (error) { } catch (error) {
toast.error("Failed to copy to clipboard"); toast.error("Failed to copy to clipboard");
@@ -249,20 +279,20 @@ export function ArtifactFileDetail({
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className="p-0"> <ArtifactContent className="p-0">
{isSupportPreview && {artifactViewState.canPreview &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={visibleContent}
isWriteFile={isWriteFile}
language={language ?? "text"} language={language ?? "text"}
scrollKey={filepathFromProps}
url={url} url={url}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none" className="size-full resize-none rounded-none border-none"
value={displayContent ?? ""} value={visibleContent ?? ""}
readonly readonly
/> />
)} )}
@@ -279,25 +309,78 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
isWriteFile,
language, language,
scrollKey,
url, url,
}: { }: {
content: string; content: string;
isWriteFile: boolean;
language: string; language: string;
scrollKey: string;
url?: string; url?: string;
}) { }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const scrollPositionRef = useRef({ x: 0, y: 0 });
const scrollMessageKey = useMemo(
() => createHtmlPreviewScrollKey(scrollKey),
[scrollKey],
);
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>(); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => { useEffect(() => {
if (language !== "html" || isWriteFile) { scrollPositionRef.current = { x: 0, y: 0 };
}, [scrollMessageKey]);
useEffect(() => {
if (language !== "html") {
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
if (!isArtifactScrollMessage(event.data, scrollMessageKey)) {
return;
}
if (event.data.type === "save") {
const x = scrollCoordinate(event.data.x);
const y = scrollCoordinate(event.data.y);
if (x !== undefined && y !== undefined) {
scrollPositionRef.current = { x, y };
}
return;
}
iframeRef.current?.contentWindow?.postMessage(
{
source: HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
key: scrollMessageKey,
type: "restore",
...scrollPositionRef.current,
},
"*",
);
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [language, scrollMessageKey]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined); setHtmlPreviewUrl(undefined);
return; return;
} }
const blob = new Blob([htmlWithBaseHref(content ?? "", url)], { const previewContent = appendHtmlPreviewScrollRestoration(
type: "text/html", appendHtmlPreviewBaseHref(content ?? "", url),
scrollKey,
);
const blob = new Blob([previewContent], {
type: "text/html;charset=utf-8",
}); });
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
setHtmlPreviewUrl(objectUrl); setHtmlPreviewUrl(objectUrl);
@@ -305,7 +388,7 @@ export function ArtifactFilePreview({
return () => { return () => {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
}; };
}, [content, isWriteFile, language, url]); }, [content, language, scrollKey, url]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
@@ -323,38 +406,110 @@ export function ArtifactFilePreview({
if (language === "html") { if (language === "html") {
return ( return (
<iframe <iframe
ref={iframeRef}
className="size-full" className="size-full"
title="Artifact preview" title="Artifact preview"
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
src={isWriteFile ? undefined : htmlPreviewUrl} src={htmlPreviewUrl}
srcDoc={isWriteFile ? content : undefined}
/> />
); );
} }
return null; return null;
} }
function htmlWithBaseHref(content: string, url?: string) { function isArtifactScrollMessage(
if (!url || /<base\s/i.exec(content)) { data: unknown,
return content; key: string,
} ): data is {
type: "save" | "restore-request";
const baseHref = htmlBaseHref(url); x?: unknown;
const baseElement = `<base href="${escapeHtmlAttribute(baseHref)}">`; y?: unknown;
if (/<head[^>]*>/i.exec(content)) { } {
return content.replace(/<head([^>]*)>/i, `<head$1>${baseElement}`); return (
} typeof data === "object" &&
return `${baseElement}${content}`; data !== null &&
"source" in data &&
data.source === HTML_PREVIEW_SCROLL_MESSAGE_SOURCE &&
"key" in data &&
data.key === key &&
"type" in data &&
(data.type === "save" || data.type === "restore-request")
);
} }
function htmlBaseHref(url: string) { function scrollCoordinate(value: unknown) {
const baseUrl = new URL(url, window.location.href); return typeof value === "number" && Number.isFinite(value)
baseUrl.pathname = baseUrl.pathname.replace(/\/[^/]*$/, "/"); ? value
baseUrl.search = ""; : undefined;
baseUrl.hash = "";
return baseUrl.toString();
} }
function escapeHtmlAttribute(value: string) { function useThrottledValue(
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;"); value: string,
intervalMs: number,
resetKey: string,
) {
const [throttledValue, setThrottledValue] = useState(value);
const latestValueRef = useRef(value);
const lastFlushAtRef = useRef(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetKeyRef = useRef(resetKey);
useEffect(() => {
latestValueRef.current = value;
if (resetKeyRef.current !== resetKey) {
resetKeyRef.current = resetKey;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
lastFlushAtRef.current = Date.now();
setThrottledValue(value);
return;
}
if (intervalMs <= 0) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
lastFlushAtRef.current = Date.now();
setThrottledValue(value);
return;
}
const now = Date.now();
const elapsed = now - lastFlushAtRef.current;
if (lastFlushAtRef.current === 0 || elapsed >= intervalMs) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
lastFlushAtRef.current = now;
setThrottledValue(value);
return;
}
if (timeoutRef.current) {
return;
}
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
lastFlushAtRef.current = Date.now();
setThrottledValue(latestValueRef.current);
}, intervalMs - elapsed);
}, [intervalMs, resetKey, value]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return intervalMs <= 0 || resetKeyRef.current !== resetKey
? value
: throttledValue;
} }
+9
View File
@@ -2,6 +2,7 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "../threads"; import type { AgentThreadState } from "../threads";
import { buildWriteFileDraftContent } from "./preview";
import { urlOfArtifact } from "./utils"; import { urlOfArtifact } from "./utils";
export async function loadArtifactContent({ export async function loadArtifactContent({
@@ -30,6 +31,14 @@ export function loadArtifactContentFromToolCall({
url: string; url: string;
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
}) { }) {
const draftContent = buildWriteFileDraftContent({
filepath: urlString,
messages: thread.messages,
});
if (draftContent !== undefined) {
return draftContent;
}
const url = new URL(urlString); const url = new URL(urlString);
const toolCallId = url.searchParams.get("tool_call_id"); const toolCallId = url.searchParams.get("tool_call_id");
const messageId = url.searchParams.get("message_id"); const messageId = url.searchParams.get("message_id");
+278
View File
@@ -0,0 +1,278 @@
export type ArtifactViewMode = "code" | "preview";
type ArtifactPreviewMessage = {
type?: string;
id?: string;
name?: string | null;
tool_call_id?: string;
content?: unknown;
tool_calls?: Array<{
id?: string;
name?: string;
args?: Record<string, unknown>;
}>;
};
export function isWriteFileArtifact(filepath: string) {
return filepath.startsWith("write-file:");
}
function hasSuccessfulWriteResult(toolResult: string | undefined) {
return toolResult?.trim() === "OK";
}
function hasFailedWriteResult(toolResult: string | undefined) {
return (
typeof toolResult === "string" && !hasSuccessfulWriteResult(toolResult)
);
}
function getTextContent(content: unknown) {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (
typeof part === "object" &&
part !== null &&
"text" in part &&
typeof part.text === "string"
) {
return part.text;
}
return "";
})
.join("")
.trim();
}
return undefined;
}
function findToolResult(
toolCallId: string,
messages: ArtifactPreviewMessage[],
) {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
return getTextContent(message.content);
}
}
return undefined;
}
function parseWriteFileArtifact(filepath: string) {
if (!isWriteFileArtifact(filepath)) {
return undefined;
}
try {
const url = new URL(filepath);
return {
path: decodeURIComponent(url.pathname),
messageId: url.searchParams.get("message_id") ?? undefined,
toolCallId: url.searchParams.get("tool_call_id") ?? undefined,
};
} catch {
return undefined;
}
}
export function buildWriteFileDraftContent({
filepath,
messages,
}: {
filepath: string;
messages: ArtifactPreviewMessage[];
}) {
const target = parseWriteFileArtifact(filepath);
if (!target) {
return undefined;
}
let draft = "";
let hasDraft = false;
for (const message of messages) {
if (message.type !== "ai") {
continue;
}
for (const toolCall of message.tool_calls ?? []) {
const args = toolCall.args ?? {};
if (
toolCall.name !== "write_file" ||
args.path !== target.path ||
typeof args.content !== "string"
) {
continue;
}
const toolCallId = toolCall.id;
const toolResult = toolCallId
? findToolResult(toolCallId, messages)
: undefined;
const isSelected =
toolCallId === target.toolCallId &&
(!target.messageId || message.id === target.messageId);
if (isSelected && hasFailedWriteResult(toolResult)) {
return undefined;
}
const shouldInclude =
hasSuccessfulWriteResult(toolResult) ||
(isSelected && toolResult === undefined);
if (!shouldInclude) {
continue;
}
if (args.append === true && hasDraft) {
draft += args.content;
} else {
draft = args.content;
}
hasDraft = true;
if (isSelected) {
return draft;
}
}
}
return hasDraft ? draft : undefined;
}
export function getArtifactViewState({
filepath,
isSupportPreview,
toolResult,
}: {
filepath: string;
isSupportPreview: boolean;
toolResult?: string;
}): {
canPreview: boolean;
initialViewMode: ArtifactViewMode;
} {
const isWriteArtifact = isWriteFileArtifact(filepath);
const canPreview =
isSupportPreview && (!isWriteArtifact || !hasFailedWriteResult(toolResult));
return {
canPreview,
initialViewMode: canPreview ? "preview" : "code",
};
}
export function appendHtmlPreviewBaseHref(
content: string,
url?: string,
currentHref = globalThis.location?.href ?? "http://localhost/",
) {
if (!url || /<base\s/i.exec(content)) {
return content;
}
const baseHref = htmlBaseHref(url, currentHref);
const baseElement = `<base href="${escapeHtmlAttribute(baseHref)}">`;
if (/<head[^>]*>/i.exec(content)) {
return content.replace(/<head([^>]*)>/i, `<head$1>${baseElement}`);
}
return `${baseElement}${content}`;
}
function htmlBaseHref(url: string, currentHref: string) {
const baseUrl = new URL(url, currentHref);
baseUrl.pathname = baseUrl.pathname.replace(/\/[^/]*$/, "/");
baseUrl.search = "";
baseUrl.hash = "";
return baseUrl.toString();
}
function escapeHtmlAttribute(value: string) {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
export const HTML_PREVIEW_SCROLL_MESSAGE_SOURCE =
"deerflow-artifact-preview-scroll";
export function createHtmlPreviewScrollKey(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `artifact-scroll:${(hash >>> 0).toString(36)}`;
}
function escapeJavaScriptString(value: string) {
return JSON.stringify(value)
.replace(/</g, "\\u003C")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function htmlScrollRestorationScript(messageKey: string) {
return `<script data-deerflow-artifact-scroll-restoration>
(() => {
const source = ${escapeJavaScriptString(HTML_PREVIEW_SCROLL_MESSAGE_SOURCE)};
const key = ${escapeJavaScriptString(messageKey)};
const post = (type, payload = {}) => {
window.parent.postMessage({ source, key, type, ...payload }, "*");
};
const save = () => {
post("save", {
x: Math.round(window.scrollX || 0),
y: Math.round(window.scrollY || 0),
});
};
const restore = (x, y) => {
if (Number.isFinite(x) && Number.isFinite(y)) {
window.scrollTo(x, y);
}
};
window.addEventListener("message", (event) => {
const data = event.data;
if (
!data ||
data.source !== source ||
data.key !== key ||
data.type !== "restore"
) {
return;
}
restore(data.x, data.y);
});
window.addEventListener("scroll", save, { passive: true });
window.addEventListener("pagehide", save);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => post("restore-request"), { once: true });
} else {
post("restore-request");
}
window.addEventListener("load", () => post("restore-request"), { once: true });
})();
</script>`;
}
export function appendHtmlPreviewScrollRestoration(
content: string,
scrollKey = "default",
) {
if (content.includes("data-deerflow-artifact-scroll-restoration")) {
return content;
}
const script = htmlScrollRestorationScript(
createHtmlPreviewScrollKey(scrollKey),
);
if (/<head(?:\s[^>]*)?>/i.test(content)) {
return content.replace(
/<head(?:\s[^>]*)?>/i,
(headTag) => `${headTag}${script}`,
);
}
if (/<\/body\s*>/i.test(content)) {
return content.replace(/<\/body\s*>/i, `${script}</body>`);
}
return `${content}${script}`;
}
+174
View File
@@ -0,0 +1,174 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
const MARKDOWN_ARTIFACT_PATH = "/artifact-fixtures/report.md";
const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json";
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
function writeFileMessages({
path = ARTIFACT_PATH,
content = "<!doctype html><html><body><h1>Report draft</h1><p>测试内容</p></body></html>",
toolResult,
}: {
path?: string;
content?: string;
toolResult?: string;
} = {}) {
const messages: unknown[] = [
{
type: "human",
id: "msg-human-artifact",
content: [{ type: "text", text: "Create a report artifact" }],
},
{
type: "ai",
id: "msg-ai-write-artifact",
content: "",
tool_calls: [
{
id: "write-file-artifact",
name: "write_file",
args: {
description: "Writing report artifact",
path,
content,
},
},
],
},
];
if (toolResult !== undefined) {
messages.push({
type: "tool",
id: "msg-tool-write-artifact",
name: "write_file",
tool_call_id: "write-file-artifact",
content: toolResult,
});
}
return messages;
}
test.describe("Artifact preview stability", () => {
test("renders preview iframe for an in-progress write artifact", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: IN_PROGRESS_THREAD_ID,
title: "Artifact preview in progress",
messages: writeFileMessages(),
},
],
});
await page.goto(`/workspace/chats/${IN_PROGRESS_THREAD_ID}`);
await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
await page.getByText(ARTIFACT_PATH).click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("report.html")).toBeVisible();
await expect(
artifactsPanel.locator('iframe[title="Artifact preview"]'),
).toBeVisible();
});
test("renders preview iframe after the write artifact succeeds", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: COMPLETE_THREAD_ID,
title: "Artifact preview complete",
messages: writeFileMessages({ toolResult: "OK" }),
},
],
});
await page.goto(`/workspace/chats/${COMPLETE_THREAD_ID}`);
await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
await page.getByText(ARTIFACT_PATH).click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("report.html")).toBeVisible();
await expect(
artifactsPanel.locator('iframe[title="Artifact preview"]'),
).toBeVisible();
});
test("renders markdown preview for an in-progress write artifact", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MARKDOWN_THREAD_ID,
title: "Markdown artifact preview in progress",
messages: writeFileMessages({
path: MARKDOWN_ARTIFACT_PATH,
content: "# Markdown draft\n\n- 测试内容 1\n- English term",
}),
},
],
});
await page.goto(`/workspace/chats/${MARKDOWN_THREAD_ID}`);
await expect(page.getByText(MARKDOWN_ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
await page.getByText(MARKDOWN_ARTIFACT_PATH).click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("report.md")).toBeVisible();
await expect(artifactsPanel.getByText("Markdown draft")).toBeVisible();
await expect(artifactsPanel.getByText("测试内容 1")).toBeVisible();
});
test("renders code view for an in-progress non-preview write artifact", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: JSON_THREAD_ID,
title: "JSON artifact code view in progress",
messages: writeFileMessages({
path: JSON_ARTIFACT_PATH,
content:
'{\n "status": "draft",\n "中文字段": "测试内容",\n "count": 3\n}',
}),
},
],
});
await page.goto(`/workspace/chats/${JSON_THREAD_ID}`);
await expect(page.getByText(JSON_ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
await page.getByText(JSON_ARTIFACT_PATH).click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("report.json")).toBeVisible();
await expect(artifactsPanel.getByText('"status": "draft"')).toBeVisible();
await expect(
artifactsPanel.getByText('"中文字段": "测试内容"'),
).toBeVisible();
});
});
+52 -4
View File
@@ -25,6 +25,8 @@ export type MockThread = {
title?: string; title?: string;
updated_at?: string; updated_at?: string;
agent_name?: string; agent_name?: string;
messages?: unknown[];
artifacts?: string[];
}; };
export type MockAgent = { export type MockAgent = {
@@ -113,7 +115,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
{ {
values: { values: {
title: matchingThread.title ?? "Untitled", title: matchingThread.title ?? "Untitled",
messages: [ messages: matchingThread.messages ?? [
{ {
type: "human", type: "human",
id: `msg-human-${matchingThread.thread_id}`, id: `msg-human-${matchingThread.thread_id}`,
@@ -125,6 +127,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
}, },
], ],
artifacts: matchingThread.artifacts ?? [],
}, },
next: [], next: [],
metadata: {}, metadata: {},
@@ -155,7 +158,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
values: { values: {
title: matchingThread?.title ?? "Untitled", title: matchingThread?.title ?? "Untitled",
messages: matchingThread messages: matchingThread
? [ ? (matchingThread.messages ?? [
{ {
type: "human", type: "human",
id: `msg-human-${matchingThread.thread_id}`, id: `msg-human-${matchingThread.thread_id}`,
@@ -166,8 +169,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
id: `msg-ai-${matchingThread.thread_id}`, id: `msg-ai-${matchingThread.thread_id}`,
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
}, },
] ])
: [], : [],
artifacts: matchingThread?.artifacts ?? [],
}, },
next: [], next: [],
metadata: {}, metadata: {},
@@ -183,15 +187,59 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
// followed by `?` or end-of-string. This must NOT match `/runs/stream`. // followed by `?` or end-of-string. This must NOT match `/runs/stream`.
void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => { void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => {
if (route.request().method() === "GET") { if (route.request().method() === "GET") {
const url = route.request().url();
const matchingThread = threads.find((t) => url.includes(t.thread_id));
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: "[]", body: JSON.stringify(
matchingThread
? [
{
run_id: `run-${matchingThread.thread_id}`,
thread_id: matchingThread.thread_id,
assistant_id: "lead_agent",
status: "success",
metadata: {},
kwargs: {},
created_at: "2025-01-01T00:00:00Z",
updated_at:
matchingThread.updated_at ?? "2025-01-01T00:00:00Z",
},
]
: [],
),
}); });
} }
return route.fallback(); return route.fallback();
}); });
void page.route(
/\/api\/threads\/([^/]+)\/runs\/([^/]+)\/messages/,
(route) => {
if (route.request().method() === "GET") {
const url = route.request().url();
const matchingThread = threads.find((t) =>
url.includes(`/api/threads/${t.thread_id}/runs/`),
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: (matchingThread?.messages ?? []).map((message, index) => ({
run_id: `run-${matchingThread?.thread_id ?? "unknown"}`,
content: message,
metadata: { caller: "lead_agent" },
created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
})),
hasMore: false,
}),
});
}
return route.fallback();
},
);
// Run stream — returns a minimal SSE response with an AI message // Run stream — returns a minimal SSE response with an AI message
void page.route("**/api/langgraph/runs/stream", handleRunStream); void page.route("**/api/langgraph/runs/stream", handleRunStream);
void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream); void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream);
@@ -0,0 +1,310 @@
import { expect, test } from "vitest";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
buildWriteFileDraftContent,
createHtmlPreviewScrollKey,
getArtifactViewState,
} from "@/core/artifacts/preview";
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
const UNSUPPORTED_ARTIFACT_PATH = "/artifact-fixtures/data.csv";
test("allows in-progress write artifacts to render a throttled preview", () => {
expect(
getArtifactViewState({
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
isSupportPreview: true,
}),
).toEqual({
canPreview: true,
initialViewMode: "preview",
});
});
test("allows preview for a write artifact once the tool call has a result", () => {
expect(
getArtifactViewState({
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
isSupportPreview: true,
toolResult: "OK",
}),
).toEqual({
canPreview: true,
initialViewMode: "preview",
});
});
test("keeps failed write artifacts in code view", () => {
expect(
getArtifactViewState({
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
isSupportPreview: true,
toolResult: "Error: Failed to write file",
}),
).toEqual({
canPreview: false,
initialViewMode: "code",
});
});
test("keeps completed artifacts on their existing preview defaults", () => {
expect(
getArtifactViewState({
filepath: ARTIFACT_PATH,
isSupportPreview: true,
}),
).toEqual({
canPreview: true,
initialViewMode: "preview",
});
});
test("keeps unsupported artifacts in code view", () => {
expect(
getArtifactViewState({
filepath: UNSUPPORTED_ARTIFACT_PATH,
isSupportPreview: false,
}),
).toEqual({
canPreview: false,
initialViewMode: "code",
});
});
test("builds a draft write-file artifact from successful writes plus the selected in-progress append", () => {
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`;
expect(
buildWriteFileDraftContent({
filepath,
messages: [
{
type: "ai",
id: "ai-1",
tool_calls: [
{
id: "call-1",
name: "write_file",
args: {
path: ARTIFACT_PATH,
content: "<!doctype html><html><body>",
},
},
],
},
{
type: "tool",
id: "tool-1",
name: "write_file",
tool_call_id: "call-1",
content: "OK",
},
{
type: "ai",
id: "ai-2",
tool_calls: [
{
id: "call-2",
name: "write_file",
args: {
append: true,
path: ARTIFACT_PATH,
content: "<p>追加内容</p>",
},
},
],
},
],
}),
).toBe("<!doctype html><html><body><p>追加内容</p>");
});
test("does not include failed writes in a draft artifact", () => {
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-3&tool_call_id=call-3`;
expect(
buildWriteFileDraftContent({
filepath,
messages: [
{
type: "ai",
id: "ai-1",
tool_calls: [
{
id: "call-1",
name: "write_file",
args: {
path: ARTIFACT_PATH,
content: "<html>",
},
},
],
},
{
type: "tool",
id: "tool-1",
name: "write_file",
tool_call_id: "call-1",
content: "OK",
},
{
type: "ai",
id: "ai-2",
tool_calls: [
{
id: "call-2",
name: "write_file",
args: {
append: true,
path: ARTIFACT_PATH,
content: "<p>失败内容</p>",
},
},
],
},
{
type: "tool",
id: "tool-2",
name: "write_file",
tool_call_id: "call-2",
content: "Error: write failed",
},
{
type: "ai",
id: "ai-3",
tool_calls: [
{
id: "call-3",
name: "write_file",
args: {
append: true,
path: ARTIFACT_PATH,
content: "</html>",
},
},
],
},
],
}),
).toBe("<html></html>");
});
test("returns undefined when the selected append failed so the caller can fall back", () => {
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`;
expect(
buildWriteFileDraftContent({
filepath,
messages: [
{
type: "ai",
id: "ai-1",
tool_calls: [
{
id: "call-1",
name: "write_file",
args: {
path: ARTIFACT_PATH,
content: "<html>",
},
},
],
},
{
type: "tool",
id: "tool-1",
name: "write_file",
tool_call_id: "call-1",
content: "OK",
},
{
type: "ai",
id: "ai-2",
tool_calls: [
{
id: "call-2",
name: "write_file",
args: {
append: true,
path: ARTIFACT_PATH,
content: "<p>失败的追加内容</p>",
},
},
],
},
{
type: "tool",
id: "tool-2",
name: "write_file",
tool_call_id: "call-2",
content: "Error: write failed",
},
],
}),
).toBeUndefined();
});
test("injects scroll restoration at the start of the HTML head", () => {
const html =
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
expect(appendHtmlPreviewScrollRestoration(html, ARTIFACT_PATH)).toContain(
"<script data-deerflow-artifact-scroll-restoration>",
);
expect(appendHtmlPreviewScrollRestoration(html, ARTIFACT_PATH)).toContain(
"<head><script data-deerflow-artifact-scroll-restoration>",
);
});
test("preserves existing head elements when injecting scroll restoration", () => {
const html =
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
const result = appendHtmlPreviewScrollRestoration(
appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html?download=true",
"http://localhost/workspace/chats/thread-1",
),
ARTIFACT_PATH,
);
expect(result).toContain(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
expect(
result.indexOf("data-deerflow-artifact-scroll-restoration"),
).toBeLessThan(
result.indexOf(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
),
);
});
test("does not duplicate HTML scroll restoration script", () => {
const html = appendHtmlPreviewScrollRestoration(
"<html><body>x</body></html>",
);
expect(
appendHtmlPreviewScrollRestoration(html).match(
/data-deerflow-artifact-scroll-restoration/g,
),
).toHaveLength(1);
});
test("scopes HTML scroll restoration without exposing the artifact path", () => {
const artifactPath =
'/artifact-fixtures/a</script><script>alert("x")</script>.html';
const html = appendHtmlPreviewScrollRestoration(
"<html><body>x</body></html>",
artifactPath,
);
expect(html).toContain(createHtmlPreviewScrollKey(artifactPath));
expect(html).toContain("window.parent.postMessage");
expect(html).not.toContain("window.name");
expect(html).not.toContain("/artifact-fixtures/a");
expect(html).not.toContain("<script>alert");
});