feat: support artifact preview

This commit is contained in:
Henry Li
2026-01-17 15:09:44 +08:00
parent 80c928fcf5
commit 0c6f8353bf
16 changed files with 482 additions and 42 deletions

View File

@@ -1,27 +1,99 @@
import { FileIcon } from "lucide-react";
import { CopyIcon, DownloadIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { toast } from "sonner";
import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactContent,
ArtifactDescription,
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
checkCodeFile,
getFileExtensionDisplayName,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useArtifacts } from "./context";
import { FileViewer } from "./file-viewer";
export function ArtifactFileDetail({
className,
filepath,
threadId,
}: {
className?: string;
filepath: string;
threadId: string;
}) {
const { setOpen } = useArtifacts();
const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]);
const { content } = useArtifactContent({
threadId,
filepath,
enabled: isCodeFile,
});
return (
<div
className={cn(
"relative flex size-full items-center justify-center",
className,
)}
>
<div className="flex size-fit items-center gap-2">
<Artifact className={cn("rounded-none", className)}>
<ArtifactHeader>
<div>
<FileIcon />
<ArtifactTitle>{getFileName(filepath)}</ArtifactTitle>
<ArtifactDescription className="mt-1 text-xs">
{getFileExtensionDisplayName(filepath)} file
</ArtifactDescription>
</div>
<div>{filepath}</div>
</div>
</div>
<div className="flex items-center gap-2">
<ArtifactActions>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
label="Copy"
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(content ?? "");
toast.success("Copied to clipboard");
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
}}
tooltip="Copy content to clipboard"
/>
)}
<a
href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction
icon={DownloadIcon}
label="Download"
onClick={() => console.log("Download")}
tooltip="Download file"
/>
</a>
<ArtifactAction
icon={XIcon}
label="Close"
onClick={() => setOpen(false)}
tooltip="Close"
/>
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent className="p-0">
<FileViewer
className="size-full"
threadId={threadId}
filepath={filepath}
/>
</ArtifactContent>
</Artifact>
);
}

View File

@@ -9,7 +9,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getFileExtension, getFileName } from "@/core/utils/files";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useArtifacts } from "./context";
@@ -17,9 +18,11 @@ import { useArtifacts } from "./context";
export function ArtifactFileList({
className,
files,
threadId,
}: {
className?: string;
files: string[];
threadId: string;
}) {
const { openArtifact } = useArtifacts();
const handleClick = useCallback(
@@ -38,12 +41,24 @@ export function ArtifactFileList({
>
<CardHeader>
<CardTitle>{getFileName(file)}</CardTitle>
<CardDescription>{getFileExtension(file)} file</CardDescription>
<CardDescription>
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction>
<Button variant="ghost">
<DownloadIcon className="size-4" />
Download
</Button>
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost">
<DownloadIcon className="size-4" />
Download
</Button>
</a>
</CardAction>
</CardHeader>
</Card>

View File

@@ -0,0 +1,64 @@
import { useMemo } from "react";
import type { BundledLanguage } from "shiki";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { checkCodeFile } from "@/core/utils/files";
import { cn } from "@/lib/utils";
export function FileViewer({
className,
filepath,
threadId,
}: {
className?: string;
filepath: string;
threadId: string;
}) {
const { isCodeFile, language } = useMemo(
() => checkCodeFile(filepath),
[filepath],
);
if (isCodeFile && language !== "html") {
return (
<CodeFileView
language={language}
filepath={filepath}
threadId={threadId}
/>
);
}
return (
<div className={cn("size-full border-none", className)}>
<iframe
className={cn("size-full border-none", className)}
src={urlOfArtifact({ filepath, threadId })}
></iframe>
</div>
);
}
function CodeFileView({
language,
filepath,
threadId,
}: {
language: BundledLanguage;
filepath: string;
threadId: string;
}) {
const { content: code } = useArtifactContent({
filepath,
threadId,
});
if (code) {
return (
<CodeBlock
className="rounded-none border-none"
language={language}
code={code}
/>
);
}
}

View File

@@ -22,9 +22,11 @@ import { MessageListSkeleton } from "./skeleton";
export function MessageList({
className,
threadId,
thread,
}: {
className?: string;
threadId: string;
thread: UseStream<AgentThreadState>;
}) {
if (thread.isThreadLoading) {
@@ -57,7 +59,11 @@ export function MessageList({
}
}
return (
<ArtifactFileList key={groupedMessages[0].id} files={files} />
<ArtifactFileList
key={groupedMessages[0].id}
files={files}
threadId={threadId}
/>
);
}
return (