deerflow2/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx

637 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type HTMLAttributes,
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactContent,
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { useMarkdownDownload } from "@/core/utils/markdown-download";
import { cn, copyToClipboard } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { useArtifacts } from "./context";
export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
}: {
className?: string;
filepath: string;
threadId: string;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
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 previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const displayContent = content ?? "";
const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({
value: artifactPath,
label: getFileName(artifactPath),
}));
}, [artifacts]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(80);
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
// 是否可以转换为docx/pdf仅markdown文件支持
const canConvertToDocxPdf = language === "markdown";
// 使用 Markdown 下载 hook
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
onError: (error, format) => {
console.error(`Failed to download as ${format}:`, error);
toast.error(`Failed to download as ${format.toUpperCase()}`);
},
});
// 下载为 DOCX
const handleDownloadDocx = useCallback(() => {
if (content) {
void downloadAsDocx(content, fileName);
}
}, [content, fileName, downloadAsDocx]);
// 下载为 PDF
const handleDownloadPdf = useCallback(() => {
if (content) {
void downloadAsPdf(content, fileName);
}
}, [content, fileName, downloadAsPdf]);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
const newFullscreen = !fullscreen;
setFullscreen(newFullscreen);
sendToParent({
type: POST_MESSAGE_TYPES.FULLSCREEN,
fullscreen: newFullscreen,
});
}, [fullscreen, setFullscreen]);
useEffect(() => {
if (previewable) {
setViewMode("preview");
} else {
setViewMode("code");
}
}, [previewable]);
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 (
// 给滚动遮挡头部定位relative
<Artifact className={cn("relative",className)}>
<ArtifactHeader>
<div className="flex items-center justify-start gap-2">
{previewable && (
<ToggleGroup
type="single"
variant={null}
size="default"
className="h-[28px]"
value={viewMode}
onValueChange={(value) => {
if (value) {
setViewMode(value as "code" | "preview");
}
}}
>
<ToggleGroupItem value="code">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6L2 9L5 12"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 3L7 15"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 6L16 9L13 12"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
>
<path
d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z"
stroke="#666666"
/>
<circle cx="8" cy="5" r="1.5" stroke="#666666" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
)}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div>
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<DropdownSelector
value={filepath}
options={artifactOptions}
onChange={select}
/>
)}
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
<ArtifactActions>
{isCodeFile && (
<ArtifactAction
label={t.clipboard.copyToClipboard}
disabled={!content}
onClick={async () => {
try {
await copyToClipboard(displayContent ?? "");
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
}}
tooltip={t.clipboard.copyToClipboard}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="2.5"
y="4.5"
width="10"
height="11"
rx="1.5"
stroke="#666666"
/>
</svg>
</ArtifactAction>
)}
{!isWriteFile && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ArtifactAction
label={t.common.download}
tooltip={t.common.download}
>
{isDownloading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
strokeLinecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
>
<DownloadIcon className="size-4" />
{t.common.downloadOriginal}
</a>
</DropdownMenuItem>
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && (
<>
<DropdownMenuItem
onClick={handleDownloadDocx}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTextIcon className="size-4" />
{isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadPdf}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTypeIcon className="size-4" />
{isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 全屏按钮 */}
<ArtifactAction
label={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
onClick={handleFullscreenToggle}
tooltip={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
>
{fullscreen ? (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
{!fullscreen && (
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
strokeLinecap="round"
/>
</svg>
</ArtifactAction>
)}
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent className=" rounded-b-[10px] bg-white p-0">
{/* 遮挡多余的滚动顶部 */}
<div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div>
{previewable &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
zoom={zoom}
/>
)}
{isCodeFile && viewMode === "code" && (
<CodeEditor
className="size-full py-[20px] resize-none rounded-none border-none"
value={displayContent ?? ""}
zoom={zoom}
readonly
/>
)}
{!isCodeFile && (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
/>
)}
</ArtifactContent>
</Artifact>
);
}
export function ArtifactFilePreview({
content,
language,
zoom = 100,
}: {
content: string;
language: string;
zoom?: number;
}) {
const zoomScale = zoom / 100;
if (language === "markdown") {
return (
<div
className={cn("size-full p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{ a: CitationLink }}
>
{content ?? ""}
</Streamdown>
</div>
);
}
if (language === "html") {
return (
<iframe
className="size-full"
title="Artifact preview"
srcDoc={content}
sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/>
);
}
return null;
}
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export type ArtifactZoomSelectorProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: number;
onChange?: (value: number) => void;
};
export const ArtifactZoomSelector = ({
value = 100,
onChange,
className,
...props
}: ArtifactZoomSelectorProps) => {
const handleZoomIn = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1];
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) {
onChange?.(nextValue);
}
};
const handleZoomOut = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const prevValue = ZOOM_LEVELS[currentIndex - 1];
if (currentIndex > 0 && prevValue !== undefined) {
onChange?.(prevValue);
}
};
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
return (
<div
className={cn(
"inline-flex h-[28px] items-center gap-1 rounded-[10px] bg-white backdrop-blur-sm",
"dark:border-gray-700/50 dark:bg-gray-800/90",
className,
)}
{...props}
>
<button
type="button"
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="放大"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M5.33325 7.5H9.7777M7.55547 5V10"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<span
className={cn(
"min-w-[36px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
{value}%
</span>
<button
type="button"
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-full w-10 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="缩小"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M4.99927 7.5H9.99927"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
);
};