mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 16:35:59 +00:00
Stabilize write artifact previews (#3172)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
pnpm-lock.yaml
|
||||
.omc/
|
||||
src/content/**/*.mdx
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -30,8 +30,16 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { findToolCallResult } from "@/core/messages/utils";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
@@ -44,6 +52,8 @@ import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
|
||||
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
@@ -55,6 +65,7 @@ export function ArtifactFileDetail({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { thread, isMock } = useThread();
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -83,6 +94,22 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [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({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
@@ -90,17 +117,20 @@ export function ArtifactFileDetail({
|
||||
});
|
||||
|
||||
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 { isMock } = useThread();
|
||||
useEffect(() => {
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
} else {
|
||||
setViewMode("code");
|
||||
}
|
||||
}, [isSupportPreview]);
|
||||
setViewMode(artifactViewState.initialViewMode);
|
||||
}, [artifactViewState.initialViewMode]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
@@ -149,7 +179,7 @@ export function ArtifactFileDetail({
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
<div className="flex min-w-0 grow items-center justify-center">
|
||||
{isSupportPreview && (
|
||||
{artifactViewState.canPreview && (
|
||||
<ToggleGroup
|
||||
className="mx-auto"
|
||||
type="single"
|
||||
@@ -209,7 +239,7 @@ export function ArtifactFileDetail({
|
||||
disabled={!content}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayContent ?? "");
|
||||
await navigator.clipboard.writeText(visibleContent ?? "");
|
||||
toast.success(t.clipboard.copiedToClipboard);
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
@@ -249,20 +279,20 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent className="p-0">
|
||||
{isSupportPreview &&
|
||||
{artifactViewState.canPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
isWriteFile={isWriteFile}
|
||||
content={visibleContent}
|
||||
language={language ?? "text"}
|
||||
scrollKey={filepathFromProps}
|
||||
url={url}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none"
|
||||
value={displayContent ?? ""}
|
||||
value={visibleContent ?? ""}
|
||||
readonly
|
||||
/>
|
||||
)}
|
||||
@@ -279,25 +309,78 @@ export function ArtifactFileDetail({
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
isWriteFile,
|
||||
language,
|
||||
scrollKey,
|
||||
url,
|
||||
}: {
|
||||
content: string;
|
||||
isWriteFile: boolean;
|
||||
language: string;
|
||||
scrollKey: 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>();
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([htmlWithBaseHref(content ?? "", url)], {
|
||||
type: "text/html",
|
||||
const previewContent = appendHtmlPreviewScrollRestoration(
|
||||
appendHtmlPreviewBaseHref(content ?? "", url),
|
||||
scrollKey,
|
||||
);
|
||||
const blob = new Blob([previewContent], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(objectUrl);
|
||||
@@ -305,7 +388,7 @@ export function ArtifactFilePreview({
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [content, isWriteFile, language, url]);
|
||||
}, [content, language, scrollKey, url]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
@@ -323,38 +406,110 @@ export function ArtifactFilePreview({
|
||||
if (language === "html") {
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="size-full"
|
||||
title="Artifact preview"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={isWriteFile ? undefined : htmlPreviewUrl}
|
||||
srcDoc={isWriteFile ? content : undefined}
|
||||
src={htmlPreviewUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function htmlWithBaseHref(content: string, url?: string) {
|
||||
if (!url || /<base\s/i.exec(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const baseHref = htmlBaseHref(url);
|
||||
const baseElement = `<base href="${escapeHtmlAttribute(baseHref)}">`;
|
||||
if (/<head[^>]*>/i.exec(content)) {
|
||||
return content.replace(/<head([^>]*)>/i, `<head$1>${baseElement}`);
|
||||
}
|
||||
return `${baseElement}${content}`;
|
||||
function isArtifactScrollMessage(
|
||||
data: unknown,
|
||||
key: string,
|
||||
): data is {
|
||||
type: "save" | "restore-request";
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
} {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
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) {
|
||||
const baseUrl = new URL(url, window.location.href);
|
||||
baseUrl.pathname = baseUrl.pathname.replace(/\/[^/]*$/, "/");
|
||||
baseUrl.search = "";
|
||||
baseUrl.hash = "";
|
||||
return baseUrl.toString();
|
||||
function scrollCoordinate(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string) {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
function useThrottledValue(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import type { AgentThreadState } from "../threads";
|
||||
|
||||
import { buildWriteFileDraftContent } from "./preview";
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
export async function loadArtifactContent({
|
||||
@@ -30,6 +31,14 @@ export function loadArtifactContentFromToolCall({
|
||||
url: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const draftContent = buildWriteFileDraftContent({
|
||||
filepath: urlString,
|
||||
messages: thread.messages,
|
||||
});
|
||||
if (draftContent !== undefined) {
|
||||
return draftContent;
|
||||
}
|
||||
|
||||
const url = new URL(urlString);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
const messageId = url.searchParams.get("message_id");
|
||||
|
||||
@@ -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("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ export type MockThread = {
|
||||
title?: string;
|
||||
updated_at?: string;
|
||||
agent_name?: string;
|
||||
messages?: unknown[];
|
||||
artifacts?: string[];
|
||||
};
|
||||
|
||||
export type MockAgent = {
|
||||
@@ -113,7 +115,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
{
|
||||
values: {
|
||||
title: matchingThread.title ?? "Untitled",
|
||||
messages: [
|
||||
messages: matchingThread.messages ?? [
|
||||
{
|
||||
type: "human",
|
||||
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}`,
|
||||
},
|
||||
],
|
||||
artifacts: matchingThread.artifacts ?? [],
|
||||
},
|
||||
next: [],
|
||||
metadata: {},
|
||||
@@ -155,7 +158,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
values: {
|
||||
title: matchingThread?.title ?? "Untitled",
|
||||
messages: matchingThread
|
||||
? [
|
||||
? (matchingThread.messages ?? [
|
||||
{
|
||||
type: "human",
|
||||
id: `msg-human-${matchingThread.thread_id}`,
|
||||
@@ -166,8 +169,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
id: `msg-ai-${matchingThread.thread_id}`,
|
||||
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
||||
},
|
||||
]
|
||||
])
|
||||
: [],
|
||||
artifacts: matchingThread?.artifacts ?? [],
|
||||
},
|
||||
next: [],
|
||||
metadata: {},
|
||||
@@ -183,15 +187,59 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
// followed by `?` or end-of-string. This must NOT match `/runs/stream`.
|
||||
void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
const url = route.request().url();
|
||||
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
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();
|
||||
});
|
||||
|
||||
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
|
||||
void page.route("**/api/langgraph/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");
|
||||
});
|
||||
Reference in New Issue
Block a user