Files
deer-flow/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx

416 lines
12 KiB
TypeScript
Raw Normal View History

2026-01-17 15:19:53 +08:00
import {
2026-01-24 23:51:11 +08:00
Code2Icon,
2026-01-17 15:19:53 +08:00
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
2026-01-24 23:51:11 +08:00
EyeIcon,
2026-01-17 15:19:53 +08:00
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
2026-01-19 19:41:46 +08:00
import { useEffect, useMemo, useState } from "react";
import rehypeKatex from "rehype-katex";
2026-01-30 16:41:18 +08:00
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
2026-01-17 15:09:44 +08:00
import { toast } from "sonner";
2026-01-19 19:41:46 +08:00
import { Streamdown } from "streamdown";
2026-01-17 00:05:19 +08:00
2026-01-17 15:09:44 +08:00
import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactContent,
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import {
InlineCitationCard,
InlineCitationCardBody,
InlineCitationSource,
} from "@/components/ai-elements/inline-citation";
import { Badge } from "@/components/ui/badge";
import { HoverCardTrigger } from "@/components/ui/hover-card";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
SelectGroup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
2026-01-19 19:41:46 +08:00
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
2026-01-21 10:46:43 +08:00
import { CodeEditor } from "@/components/workspace/code-editor";
2026-01-17 15:09:44 +08:00
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
2026-01-30 16:41:18 +08:00
import {
buildCitationMap,
extractDomainFromUrl,
parseCitations,
type Citation,
} from "@/core/citations";
2026-01-20 14:06:47 +08:00
import { useI18n } from "@/core/i18n/hooks";
2026-01-30 16:41:18 +08:00
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
2026-01-17 11:02:33 +08:00
import { cn } from "@/lib/utils";
2026-01-17 00:05:19 +08:00
2026-01-17 15:09:44 +08:00
import { useArtifacts } from "./context";
2026-01-17 11:02:33 +08:00
export function ArtifactFileDetail({
className,
2026-01-18 17:13:15 +08:00
filepath: filepathFromProps,
2026-01-17 15:09:44 +08:00
threadId,
2026-01-17 11:02:33 +08:00
}: {
className?: string;
filepath: string;
2026-01-17 15:09:44 +08:00
threadId: string;
2026-01-17 11:02:33 +08:00
}) {
2026-01-20 14:06:47 +08:00
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
2026-01-18 17:13:15 +08:00
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepathFromProps);
2026-01-18 20:26:01 +08:00
return decodeURIComponent(url.pathname);
2026-01-18 17:13:15 +08:00
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
2026-01-19 19:41:46 +08:00
const { isCodeFile, language } = useMemo(() => {
2026-01-18 17:13:15 +08:00
if (isWriteFile) {
let language = checkCodeFile(filepath).language;
2026-01-19 19:41:46 +08:00
language ??= "text";
2026-01-18 17:13:15 +08:00
return { isCodeFile: true, language };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile]);
2026-01-19 19:41:46 +08:00
const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
2026-01-17 15:09:44 +08:00
const { content } = useArtifactContent({
threadId,
2026-01-18 17:13:15 +08:00
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
2026-01-17 15:09:44 +08:00
});
2026-01-30 16:41:18 +08:00
// Parse citations and get clean content for code editor
const cleanContent = useMemo(() => {
if (language === "markdown" && content) {
return parseCitations(content).cleanContent;
}
return content;
}, [content, language]);
2026-01-30 16:41:18 +08:00
2026-01-19 19:41:46 +08:00
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
useEffect(() => {
if (previewable) {
setViewMode("preview");
} else {
setViewMode("code");
}
}, [previewable]);
2026-01-17 00:02:03 +08:00
return (
2026-01-21 08:50:15 +08:00
<Artifact className={cn(className)}>
<ArtifactHeader className="px-2">
2026-01-19 19:41:46 +08:00
<div className="flex items-center gap-2">
<ArtifactTitle>
2026-01-18 17:13:15 +08:00
{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>
2026-01-17 15:09:44 +08:00
</div>
2026-01-19 19:41:46 +08:00
<div className="flex min-w-0 grow items-center justify-center">
{previewable && (
<ToggleGroup
className="mx-auto"
type="single"
variant="outline"
size="sm"
value={viewMode}
onValueChange={(value) =>
setViewMode(value as "code" | "preview")
}
>
2026-01-25 00:06:49 +08:00
<ToggleGroupItem value="code">
<Code2Icon />
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<EyeIcon />
</ToggleGroupItem>
2026-01-19 19:41:46 +08:00
</ToggleGroup>
)}
</div>
2026-01-17 15:09:44 +08:00
<div className="flex items-center gap-2">
<ArtifactActions>
2026-01-18 17:13:15 +08:00
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
2026-01-20 14:06:47 +08:00
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
2026-01-18 17:13:15 +08:00
/>
</a>
)}
2026-01-17 15:09:44 +08:00
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
2026-01-20 14:06:47 +08:00
label={t.clipboard.copyToClipboard}
2026-01-17 15:09:44 +08:00
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(content ?? "");
2026-01-20 14:06:47 +08:00
toast.success(t.clipboard.copiedToClipboard);
2026-01-17 15:09:44 +08:00
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
}}
2026-01-20 14:06:47 +08:00
tooltip={t.clipboard.copyToClipboard}
2026-01-17 15:09:44 +08:00
/>
)}
2026-01-18 17:13:15 +08:00
{!isWriteFile && (
<a
href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction
icon={DownloadIcon}
2026-01-20 14:06:47 +08:00
label={t.common.download}
tooltip={t.common.download}
2026-01-18 17:13:15 +08:00
/>
</a>
)}
2026-01-17 15:09:44 +08:00
<ArtifactAction
icon={XIcon}
2026-01-20 14:06:47 +08:00
label={t.common.close}
2026-01-17 15:09:44 +08:00
onClick={() => setOpen(false)}
2026-01-20 14:06:47 +08:00
tooltip={t.common.close}
2026-01-17 15:09:44 +08:00
/>
</ArtifactActions>
2026-01-17 00:02:03 +08:00
</div>
2026-01-17 15:09:44 +08:00
</ArtifactHeader>
<ArtifactContent className="p-0">
2026-01-19 19:41:46 +08:00
{previewable && viewMode === "preview" && (
<ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={content}
language={language ?? "text"}
/>
)}
{isCodeFile && viewMode === "code" && (
2026-01-21 09:33:33 +08:00
<CodeEditor
2026-01-19 19:41:46 +08:00
className="size-full resize-none rounded-none border-none"
value={cleanContent ?? ""}
2026-01-21 09:33:33 +08:00
readonly
2026-01-19 19:41:46 +08:00
/>
)}
{!isCodeFile && (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
/>
)}
2026-01-17 15:09:44 +08:00
</ArtifactContent>
</Artifact>
2026-01-17 00:02:03 +08:00
);
}
2026-01-19 19:41:46 +08:00
export function ArtifactFilePreview({
filepath,
threadId,
content,
language,
}: {
filepath: string;
threadId: string;
content: string;
language: string;
}) {
const { citations, cleanContent, citationMap } = React.useMemo(() => {
const parsed = parseCitations(content ?? "");
const map = buildCitationMap(parsed.citations);
return {
citations: parsed.citations,
cleanContent: parsed.cleanContent,
citationMap: map,
};
}, [content]);
2026-01-19 19:41:46 +08:00
if (language === "markdown") {
return (
<div className="size-full px-4">
<Streamdown
className="size-full"
2026-01-30 16:41:18 +08:00
{...streamdownPlugins}
components={{
a: ({
href,
children,
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (!href) {
return <span>{children}</span>;
}
// Check if it's a citation link
const citation = citationMap.get(href);
if (citation) {
return (
<ArtifactCitationLink citation={citation} href={href}>
{children}
</ArtifactCitationLink>
);
}
// Check if it's an external link (http/https)
const isExternalLink =
href.startsWith("http://") || href.startsWith("https://");
if (isExternalLink) {
return (
<ExternalLinkBadge href={href}>{children}</ExternalLinkBadge>
);
}
// Internal/anchor link
return (
<a href={href} className="text-primary hover:underline">
{children}
</a>
);
},
}}
>
{cleanContent ?? ""}
</Streamdown>
2026-01-19 19:41:46 +08:00
</div>
);
}
if (language === "html") {
return (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
/>
);
}
return null;
}
/**
* Citation link component for artifact preview (with full citation data)
*/
function ArtifactCitationLink({
citation,
href,
children,
}: {
citation: Citation;
href: string;
children: React.ReactNode;
}) {
const domain = extractDomainFromUrl(href);
return (
<InlineCitationCard>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
onClick={(e) => e.stopPropagation()}
>
<Badge
variant="secondary"
2026-01-30 16:41:18 +08:00
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{children ?? domain}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<InlineCitationCardBody>
<div className="p-3">
<InlineCitationSource
title={citation.title}
url={href}
description={citation.snippet}
/>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</InlineCitationCardBody>
</InlineCitationCard>
);
}
/**
* External link badge component for artifact preview
*/
function ExternalLinkBadge({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const domain = extractDomainFromUrl(href);
return (
<InlineCitationCard>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<Badge
variant="secondary"
2026-01-30 16:41:18 +08:00
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{children ?? domain}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<InlineCitationCardBody>
<div className="p-3">
<InlineCitationSource title={domain} url={href} />
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</InlineCitationCardBody>
</InlineCitationCard>
);
}