mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-16 11:24:45 +08:00
feat: support artifact preview
This commit is contained in:
40
frontend/src/components/ui/sonner.tsx
Normal file
40
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
frontend/src/components/workspace/artifacts/file-viewer.tsx
Normal file
64
frontend/src/components/workspace/artifacts/file-viewer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user