fix(frontend): 恢复自定义 artifact-file-detail 实现

This commit is contained in:
肖应宇 2026-03-29 00:06:55 +08:00
parent a34622c45c
commit a0ce3d8b08
1 changed files with 448 additions and 107 deletions

View File

@ -1,14 +1,11 @@
import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react";
import { import {
Code2Icon, useCallback,
CopyIcon, useEffect,
DownloadIcon, useMemo,
EyeIcon, useState,
LoaderIcon, type HTMLAttributes,
PackageIcon, } from "react";
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@ -20,27 +17,26 @@ import {
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { Select, SelectItem } from "@/components/ui/select";
import { import {
SelectContent, DropdownMenu,
SelectGroup, DropdownMenuContent,
SelectTrigger, DropdownMenuItem,
SelectValue, DropdownMenuTrigger,
} from "@/components/ui/select"; } from "@/components/ui/dropdown-menu";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
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 { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
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";
import { env } from "@/env"; import { useMarkdownDownload } from "@/core/utils/markdown-download";
import { cn } from "@/lib/utils"; import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils";
import { ArtifactLink } from "../citations/artifact-link"; import { CitationLink } from "../citations/citation-link";
import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@ -54,7 +50,8 @@ export function ArtifactFileDetail({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@ -80,9 +77,9 @@ export function ArtifactFileDetail({
} }
return checkCodeFile(filepath); return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]); }, [filepath, isWriteFile, isSkillFile]);
const isSupportPreview = useMemo(() => { const previewable = useMemo(() => {
return language === "html" || language === "markdown"; return (language === "html" && !isWriteFile) || language === "markdown";
}, [language]); }, [isWriteFile, language]);
const { content } = useArtifactContent({ const { content } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
@ -91,16 +88,62 @@ export function ArtifactFileDetail({
const displayContent = content ?? ""; const displayContent = content ?? "";
const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({
value: artifactPath,
label: getFileName(artifactPath),
}));
}, [artifacts]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const { isMock } = useThread(); 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(() => { useEffect(() => {
if (isSupportPreview) { if (previewable) {
setViewMode("preview"); setViewMode("preview");
} else { } else {
setViewMode("code"); setViewMode("code");
} }
}, [isSupportPreview]); }, [previewable]);
const handleInstallSkill = useCallback(async () => { const handleInstallSkill = useCallback(async () => {
if (isInstalling) return; if (isInstalling) return;
@ -123,38 +166,18 @@ export function ArtifactFileDetail({
setIsInstalling(false); setIsInstalling(false);
} }
}, [threadId, filepath, isInstalling]); }, [threadId, filepath, isInstalling]);
return ( return (
<Artifact className={cn(className)}> // 给滚动遮挡头部定位relative
<ArtifactHeader className="px-2"> <Artifact className={cn("relative",className)}>
<div className="flex items-center gap-2"> <ArtifactHeader>
<ArtifactTitle> <div className="flex items-center justify-start gap-2">
{isWriteFile ? ( {previewable && (
<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">
{isSupportPreview && (
<ToggleGroup <ToggleGroup
className="mx-auto"
type="single" type="single"
variant="outline" variant={null}
size="sm" size="default"
className="h-[28px]"
value={viewMode} value={viewMode}
onValueChange={(value) => { onValueChange={(value) => {
if (value) { if (value) {
@ -163,47 +186,75 @@ export function ArtifactFileDetail({
}} }}
> >
<ToggleGroupItem value="code"> <ToggleGroupItem value="code">
<Code2Icon /> <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>
<ToggleGroupItem value="preview"> <ToggleGroupItem value="preview">
<EyeIcon /> <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> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
{isWriteFile ? (
<div className=" w-full text-center overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
) : (
<DropdownSelector
value={filepath}
options={artifactOptions}
onChange={select}
/>
)}
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
<ArtifactActions> <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 && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
/>
</a>
)}
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
icon={CopyIcon}
label={t.clipboard.copyToClipboard} label={t.clipboard.copyToClipboard}
disabled={!content} disabled={!content}
onClick={async () => { onClick={async () => {
try { try {
await navigator.clipboard.writeText(displayContent ?? ""); await copyToClipboard(displayContent ?? "");
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");
@ -211,49 +262,210 @@ export function ArtifactFileDetail({
} }
}} }}
tooltip={t.clipboard.copyToClipboard} 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 && ( {!isWriteFile && (
<a <DropdownMenu>
href={urlOfArtifact({ filepath, threadId, download: true })} <DropdownMenuTrigger asChild>
target="_blank" <ArtifactAction
> label={t.common.download}
<ArtifactAction tooltip={t.common.download}
icon={DownloadIcon} >
label={t.common.download} {isDownloading ? (
tooltip={t.common.download} <LoaderIcon className="size-4 animate-spin" />
/> ) : (
</a> <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 <ArtifactAction
icon={XIcon} label={
label={t.common.close} fullscreen ? t.common.closeFullScreen : t.common.fullScreen
onClick={() => setOpen(false)} }
tooltip={t.common.close} 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> </ArtifactActions>
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className="p-0"> <ArtifactContent className=" rounded-b-[10px] bg-white p-0">
{isSupportPreview && {/* 遮挡多余的滚动顶部 */}
<div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div>
{previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
zoom={zoom}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none" className="size-full py-[20px] resize-none rounded-none border-none"
value={displayContent ?? ""} value={displayContent ?? ""}
zoom={zoom}
readonly readonly
/> />
)} )}
{!isCodeFile && ( {!isCodeFile && (
<iframe <iframe
className="size-full" className="size-full"
src={urlOfArtifact({ filepath, threadId, isMock })} src={urlOfArtifact({ filepath, threadId })}
/> />
)} )}
</ArtifactContent> </ArtifactContent>
@ -264,17 +476,24 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
language, language,
zoom = 100,
}: { }: {
content: string; content: string;
language: string; language: string;
zoom?: number;
}) { }) {
const zoomScale = zoom / 100;
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div className="size-full px-4"> <div
className={cn("size-full p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: ArtifactLink }} components={{ a: CitationLink }}
> >
{content ?? ""} {content ?? ""}
</Streamdown> </Streamdown>
@ -288,8 +507,130 @@ export function ArtifactFilePreview({
title="Artifact preview" title="Artifact preview"
srcDoc={content} srcDoc={content}
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/> />
); );
} }
return null; 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>
);
};