mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 01:15:58 +00:00
5b81588b87
* fix(frontend): fallback streamdown clipboard copy * fix(frontend): address clipboard fallback review * fix(frontend): normalize clipboard fallback rejection * fix(frontend): harden clipboard fallback install * fix(frontend): clarify clipboard fallback errors * fix(frontend): cover clipboard fallback edge cases * fix(frontend): tighten clipboard fallback cleanup * fix(frontend): reduce clipboard fallback copy window * fix(frontend): guard clipboard item fallback install * fix(frontend): clean up clipboard fallback on selection errors * Address clipboard fallback review feedback * fix(frontend): guard clipboard fallback install during SSR
523 lines
15 KiB
TypeScript
523 lines
15 KiB
TypeScript
import {
|
|
Code2Icon,
|
|
CopyIcon,
|
|
DownloadIcon,
|
|
EyeIcon,
|
|
LoaderIcon,
|
|
PackageIcon,
|
|
SquareArrowOutUpRightIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
Artifact,
|
|
ArtifactAction,
|
|
ArtifactActions,
|
|
ArtifactContent,
|
|
ArtifactHeader,
|
|
ArtifactTitle,
|
|
} from "@/components/ai-elements/artifact";
|
|
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
|
import { Select, SelectItem } from "@/components/ui/select";
|
|
import {
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
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 { writeTextToClipboard } from "@/core/clipboard";
|
|
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";
|
|
import { env } from "@/env";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { ArtifactLink } from "../citations/artifact-link";
|
|
import { useThread } from "../messages/context";
|
|
import { Tooltip } from "../tooltip";
|
|
|
|
import { useArtifacts } from "./context";
|
|
|
|
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
|
|
|
|
export function ArtifactFileDetail({
|
|
className,
|
|
filepath: filepathFromProps,
|
|
threadId,
|
|
}: {
|
|
className?: string;
|
|
filepath: string;
|
|
threadId: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { artifacts, setOpen, select } = useArtifacts();
|
|
const { thread, isMock } = useThread();
|
|
const isWriteFile = useMemo(() => {
|
|
return filepathFromProps.startsWith("write-file:");
|
|
}, [filepathFromProps]);
|
|
const filepath = useMemo(() => {
|
|
if (isWriteFile) {
|
|
const url = new URL(filepathFromProps);
|
|
return decodeURIComponent(url.pathname);
|
|
}
|
|
return filepathFromProps;
|
|
}, [filepathFromProps, isWriteFile]);
|
|
const isSkillFile = useMemo(() => {
|
|
return filepath.endsWith(".skill");
|
|
}, [filepath]);
|
|
const { isCodeFile, language } = useMemo(() => {
|
|
if (isWriteFile) {
|
|
let language = checkCodeFile(filepath).language;
|
|
language ??= "text";
|
|
return { isCodeFile: true, language };
|
|
}
|
|
// Treat .skill files as markdown (they contain SKILL.md)
|
|
if (isSkillFile) {
|
|
return { isCodeFile: true, language: "markdown" };
|
|
}
|
|
return checkCodeFile(filepath);
|
|
}, [filepath, isWriteFile, isSkillFile]);
|
|
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,
|
|
enabled: isCodeFile && !isWriteFile,
|
|
});
|
|
|
|
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">(
|
|
artifactViewState.initialViewMode,
|
|
);
|
|
const [isInstalling, setIsInstalling] = useState(false);
|
|
useEffect(() => {
|
|
setViewMode(artifactViewState.initialViewMode);
|
|
}, [artifactViewState.initialViewMode]);
|
|
|
|
const handleInstallSkill = useCallback(async () => {
|
|
if (isInstalling) return;
|
|
|
|
setIsInstalling(true);
|
|
try {
|
|
const result = await installSkill({
|
|
thread_id: threadId,
|
|
path: filepath,
|
|
});
|
|
if (result.success) {
|
|
toast.success(result.message);
|
|
} else {
|
|
toast.error(result.message ?? "Failed to install skill");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to install skill:", error);
|
|
toast.error("Failed to install skill");
|
|
} finally {
|
|
setIsInstalling(false);
|
|
}
|
|
}, [threadId, filepath, isInstalling]);
|
|
return (
|
|
<Artifact className={cn(className)}>
|
|
<ArtifactHeader className="px-2">
|
|
<div className="flex items-center gap-2">
|
|
<ArtifactTitle>
|
|
{isWriteFile ? (
|
|
<div className="px-2">{getFileName(filepath)}</div>
|
|
) : (
|
|
<Select value={filepath} onValueChange={select}>
|
|
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
|
|
<SelectValue placeholder="Select a file" />
|
|
</SelectTrigger>
|
|
<SelectContent className="select-none">
|
|
<SelectGroup>
|
|
{(artifacts ?? []).map((filepath) => (
|
|
<SelectItem key={filepath} value={filepath}>
|
|
{getFileName(filepath)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</ArtifactTitle>
|
|
</div>
|
|
<div className="flex min-w-0 grow items-center justify-center">
|
|
{artifactViewState.canPreview && (
|
|
<ToggleGroup
|
|
className="mx-auto"
|
|
type="single"
|
|
variant="outline"
|
|
size="sm"
|
|
value={viewMode}
|
|
onValueChange={(value) => {
|
|
if (value) {
|
|
setViewMode(value as "code" | "preview");
|
|
}
|
|
}}
|
|
>
|
|
<ToggleGroupItem value="code">
|
|
<Code2Icon />
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="preview">
|
|
<EyeIcon />
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ArtifactActions>
|
|
{!isWriteFile && filepath.endsWith(".skill") && (
|
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
|
<ArtifactAction
|
|
icon={isInstalling ? LoaderIcon : PackageIcon}
|
|
label={t.common.install}
|
|
tooltip={t.common.install}
|
|
disabled={
|
|
isInstalling ||
|
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
|
}
|
|
onClick={handleInstallSkill}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
{!isWriteFile && (
|
|
<ArtifactAction
|
|
icon={SquareArrowOutUpRightIcon}
|
|
label={t.common.openInNewWindow}
|
|
tooltip={t.common.openInNewWindow}
|
|
onClick={() => {
|
|
const w = window.open(
|
|
urlOfArtifact({ filepath, threadId, isMock }),
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
if (w) w.opener = null;
|
|
}}
|
|
/>
|
|
)}
|
|
{isCodeFile && (
|
|
<ArtifactAction
|
|
icon={CopyIcon}
|
|
label={t.clipboard.copyToClipboard}
|
|
disabled={!content}
|
|
onClick={() => {
|
|
void (async () => {
|
|
const didCopy = await writeTextToClipboard(
|
|
visibleContent ?? "",
|
|
);
|
|
if (!didCopy) {
|
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
|
return;
|
|
}
|
|
|
|
toast.success(t.clipboard.copiedToClipboard);
|
|
})().catch(() => {
|
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
|
});
|
|
}}
|
|
tooltip={t.clipboard.copyToClipboard}
|
|
/>
|
|
)}
|
|
{!isWriteFile && (
|
|
<ArtifactAction
|
|
icon={DownloadIcon}
|
|
label={t.common.download}
|
|
tooltip={t.common.download}
|
|
onClick={() => {
|
|
const w = window.open(
|
|
urlOfArtifact({
|
|
filepath,
|
|
threadId,
|
|
download: true,
|
|
isMock,
|
|
}),
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
if (w) w.opener = null;
|
|
}}
|
|
/>
|
|
)}
|
|
<ArtifactAction
|
|
icon={XIcon}
|
|
label={t.common.close}
|
|
onClick={() => setOpen(false)}
|
|
tooltip={t.common.close}
|
|
/>
|
|
</ArtifactActions>
|
|
</div>
|
|
</ArtifactHeader>
|
|
<ArtifactContent className="p-0">
|
|
{artifactViewState.canPreview &&
|
|
viewMode === "preview" &&
|
|
(language === "markdown" || language === "html") && (
|
|
<ArtifactFilePreview
|
|
content={visibleContent}
|
|
language={language ?? "text"}
|
|
scrollKey={filepathFromProps}
|
|
url={url}
|
|
/>
|
|
)}
|
|
{isCodeFile && viewMode === "code" && (
|
|
<CodeEditor
|
|
className="size-full resize-none rounded-none border-none"
|
|
value={visibleContent ?? ""}
|
|
readonly
|
|
/>
|
|
)}
|
|
{!isCodeFile && (
|
|
<iframe
|
|
className="size-full"
|
|
src={urlOfArtifact({ filepath, threadId, isMock })}
|
|
/>
|
|
)}
|
|
</ArtifactContent>
|
|
</Artifact>
|
|
);
|
|
}
|
|
|
|
export function ArtifactFilePreview({
|
|
content,
|
|
language,
|
|
scrollKey,
|
|
url,
|
|
}: {
|
|
content: string;
|
|
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(() => {
|
|
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 previewContent = appendHtmlPreviewScrollRestoration(
|
|
appendHtmlPreviewBaseHref(content ?? "", url),
|
|
scrollKey,
|
|
);
|
|
const blob = new Blob([previewContent], {
|
|
type: "text/html;charset=utf-8",
|
|
});
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
setHtmlPreviewUrl(objectUrl);
|
|
|
|
return () => {
|
|
URL.revokeObjectURL(objectUrl);
|
|
};
|
|
}, [content, language, scrollKey, url]);
|
|
|
|
if (language === "markdown") {
|
|
return (
|
|
<div className="size-full px-4">
|
|
<ClipboardSafeStreamdown
|
|
className="size-full"
|
|
{...streamdownPlugins}
|
|
components={{ a: ArtifactLink }}
|
|
>
|
|
{content ?? ""}
|
|
</ClipboardSafeStreamdown>
|
|
</div>
|
|
);
|
|
}
|
|
if (language === "html") {
|
|
return (
|
|
<iframe
|
|
ref={iframeRef}
|
|
className="size-full"
|
|
title="Artifact preview"
|
|
sandbox="allow-scripts allow-forms"
|
|
src={htmlPreviewUrl}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 scrollCoordinate(value: unknown) {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
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;
|
|
}
|