feat: support artifact preview

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

View File

@@ -39,7 +39,7 @@ def create_app() -> FastAPI:
# Add CORS middleware # Add CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=config.cors_origins, allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -1,8 +1,9 @@
import mimetypes
import os import os
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
# Base directory for thread data (relative to backend/) # Base directory for thread data (relative to backend/)
THREAD_DATA_BASE_DIR = ".deer-flow/threads" THREAD_DATA_BASE_DIR = ".deer-flow/threads"
@@ -47,8 +48,19 @@ def _resolve_artifact_path(thread_id: str, artifact_path: str) -> Path:
return actual_path return actual_path
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes."""
try:
with open(path, "rb") as f:
chunk = f.read(sample_size)
# Text files shouldn't contain null bytes
return b"\x00" not in chunk
except Exception:
return False
@router.get("/threads/{thread_id}/artifacts/{path:path}") @router.get("/threads/{thread_id}/artifacts/{path:path}")
async def get_artifact(thread_id: str, path: str) -> FileResponse: async def get_artifact(thread_id: str, path: str, request: Request) -> FileResponse:
"""Get an artifact file by its path. """Get an artifact file by its path.
Args: Args:
@@ -69,7 +81,19 @@ async def get_artifact(thread_id: str, path: str) -> FileResponse:
if not actual_path.is_file(): if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}") raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
return FileResponse( mime_type, _ = mimetypes.guess_type(actual_path)
path=actual_path,
filename=actual_path.name, # if `download` query parameter is true, return the file as a download
) if request.query_params.get("download"):
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f'attachment; filename="{actual_path.name}"'})
if mime_type and mime_type == "text/html":
return HTMLResponse(content=actual_path.read_text())
if mime_type and mime_type.startswith("text/"):
return PlainTextResponse(content=actual_path.read_text(), media_type=mime_type)
if is_text_file_by_content(actual_path):
return PlainTextResponse(content=actual_path.read_text(), media_type=mime_type)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": f'inline; filename="{actual_path.name}"'})

View File

@@ -51,6 +51,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1", "react-resizable-panels": "^4.4.1",
"shiki": "3.15.0", "shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0", "streamdown": "1.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",

View File

@@ -113,6 +113,9 @@ importers:
shiki: shiki:
specifier: 3.15.0 specifier: 3.15.0
version: 3.15.0 version: 3.15.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
streamdown: streamdown:
specifier: 1.4.0 specifier: 1.4.0
version: 1.4.0(@types/react@19.2.8)(react@19.2.3) version: 1.4.0(@types/react@19.2.8)(react@19.2.3)
@@ -4274,6 +4277,12 @@ packages:
peerDependencies: peerDependencies:
vue: ^3 vue: ^3
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -9287,6 +9296,11 @@ snapshots:
ufo: 1.6.2 ufo: 1.6.2
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {} space-separated-tokens@2.0.2: {}

View File

@@ -4,6 +4,7 @@ import { FilesIcon, XIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ResizableHandle, ResizableHandle,
@@ -25,7 +26,6 @@ import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
export default function ChatPage() { export default function ChatPage() {
const router = useRouter(); const router = useRouter();
@@ -81,7 +81,7 @@ export default function ChatPage() {
minSize={30} minSize={30}
> >
<div className="relative flex size-full min-h-0 justify-between"> <div className="relative flex size-full min-h-0 justify-between">
<header className="absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4 drop-shadow-2xl backdrop-blur"> <header className="bg-background/80 absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4 drop-shadow-2xl backdrop-blur">
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
<FlipDisplay <FlipDisplay
uniqueKey={title} uniqueKey={title}
@@ -109,7 +109,11 @@ export default function ChatPage() {
</header> </header>
<main className="flex min-h-0 grow flex-col"> <main className="flex min-h-0 grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
<MessageList className="size-full" thread={thread} /> <MessageList
className="size-full"
threadId={threadId!}
thread={thread}
/>
</div> </div>
<div className="absolute right-0 bottom-0 left-0 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 flex justify-center px-4">
<InputBox <InputBox
@@ -139,17 +143,6 @@ export default function ChatPage() {
defaultSize={artifactsOpen ? 64 : 0} defaultSize={artifactsOpen ? 64 : 0}
minSize={0} minSize={0}
> >
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<div <div
className={cn( className={cn(
"h-full transition-transform duration-300 ease-in-out", "h-full transition-transform duration-300 ease-in-out",
@@ -160,9 +153,21 @@ export default function ChatPage() {
<ArtifactFileDetail <ArtifactFileDetail
className="size-full" className="size-full"
filepath={selectedArtifact} filepath={selectedArtifact}
threadId={threadId!}
/> />
) : ( ) : (
<div className="flex size-full items-center justify-center"> <div className="relative flex size-full items-center justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title="No artifact selected" title="No artifact selected"

View File

@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Overscroll } from "@/components/workspace/overscroll"; import { Overscroll } from "@/components/workspace/overscroll";
@@ -41,6 +42,7 @@ export default function WorkspaceLayout({
<WorkspaceSidebar /> <WorkspaceSidebar />
<SidebarInset>{children}</SidebarInset> <SidebarInset>{children}</SidebarInset>
</SidebarProvider> </SidebarProvider>
<Toaster position="top-center" />
</QueryClientProvider> </QueryClientProvider>
); );
} }

View 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 }

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 { cn } from "@/lib/utils";
import { useArtifacts } from "./context";
import { FileViewer } from "./file-viewer";
export function ArtifactFileDetail({ export function ArtifactFileDetail({
className, className,
filepath, filepath,
threadId,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
threadId: string;
}) { }) {
const { setOpen } = useArtifacts();
const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]);
const { content } = useArtifactContent({
threadId,
filepath,
enabled: isCodeFile,
});
return ( return (
<div <Artifact className={cn("rounded-none", className)}>
className={cn( <ArtifactHeader>
"relative flex size-full items-center justify-center",
className,
)}
>
<div className="flex size-fit items-center gap-2">
<div> <div>
<FileIcon /> <ArtifactTitle>{getFileName(filepath)}</ArtifactTitle>
<ArtifactDescription className="mt-1 text-xs">
{getFileExtensionDisplayName(filepath)} file
</ArtifactDescription>
</div> </div>
<div>{filepath}</div> <div className="flex items-center gap-2">
</div> <ArtifactActions>
</div> {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, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { cn } from "@/lib/utils";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@@ -17,9 +18,11 @@ import { useArtifacts } from "./context";
export function ArtifactFileList({ export function ArtifactFileList({
className, className,
files, files,
threadId,
}: { }: {
className?: string; className?: string;
files: string[]; files: string[];
threadId: string;
}) { }) {
const { openArtifact } = useArtifacts(); const { openArtifact } = useArtifacts();
const handleClick = useCallback( const handleClick = useCallback(
@@ -38,12 +41,24 @@ export function ArtifactFileList({
> >
<CardHeader> <CardHeader>
<CardTitle>{getFileName(file)}</CardTitle> <CardTitle>{getFileName(file)}</CardTitle>
<CardDescription>{getFileExtension(file)} file</CardDescription> <CardDescription>
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction> <CardAction>
<Button variant="ghost"> <a
<DownloadIcon className="size-4" /> href={urlOfArtifact({
Download filepath: file,
</Button> threadId: threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost">
<DownloadIcon className="size-4" />
Download
</Button>
</a>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
</Card> </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({ export function MessageList({
className, className,
threadId,
thread, thread,
}: { }: {
className?: string; className?: string;
threadId: string;
thread: UseStream<AgentThreadState>; thread: UseStream<AgentThreadState>;
}) { }) {
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
@@ -57,7 +59,11 @@ export function MessageList({
} }
} }
return ( return (
<ArtifactFileList key={groupedMessages[0].id} files={files} /> <ArtifactFileList
key={groupedMessages[0].id}
files={files}
threadId={threadId}
/>
); );
} }
return ( return (

View File

@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { loadArtifactContent } from "./loader";
export function useArtifactContent({
filepath,
threadId,
enabled,
}: {
filepath: string;
threadId: string;
enabled?: boolean;
}) {
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId],
queryFn: () => loadArtifactContent({ filepath, threadId }),
enabled,
});
return { content: data, isLoading, error };
}

View File

@@ -0,0 +1 @@
export * from "./loader";

View File

@@ -0,0 +1,14 @@
import { urlOfArtifact } from "./utils";
export async function loadArtifactContent({
filepath,
threadId,
}: {
filepath: string;
threadId: string;
}) {
const url = urlOfArtifact({ filepath, threadId });
const response = await fetch(url);
const text = await response.text();
return text;
}

View File

@@ -0,0 +1,11 @@
export function urlOfArtifact({
filepath,
threadId,
download = false,
}: {
filepath: string;
threadId: string;
download?: boolean;
}) {
return `http://localhost:8000/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}

View File

@@ -1,8 +1,159 @@
import type { BundledLanguage } from "shiki";
const extensionMap: Record<string, BundledLanguage> = {
// JavaScript/TypeScript ecosystem
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
mjs: "javascript",
cjs: "javascript",
mts: "typescript",
cts: "typescript",
// Web
html: "html",
htm: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
vue: "vue",
svelte: "svelte",
astro: "astro",
// Python
py: "python",
pyi: "python",
pyw: "python",
// Java/JVM
java: "java",
kt: "kotlin",
kts: "kotlin",
scala: "scala",
groovy: "groovy",
// C/C++
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
hxx: "cpp",
hh: "cpp",
// C#
cs: "csharp",
// Go
go: "go",
// Rust
rs: "rust",
// Ruby
rb: "ruby",
rake: "ruby",
// PHP
php: "php",
// Shell/Bash
sh: "bash",
bash: "bash",
zsh: "zsh",
fish: "fish",
// Config & Data
json: "json",
jsonc: "jsonc",
json5: "json5",
yaml: "yaml",
yml: "yaml",
toml: "toml",
xml: "xml",
ini: "ini",
env: "dotenv",
// Markdown & Docs
md: "markdown",
mdx: "mdx",
rst: "rst",
// SQL
sql: "sql",
// Other languages
swift: "swift",
dart: "dart",
lua: "lua",
r: "r",
matlab: "matlab",
julia: "jl",
elm: "elm",
haskell: "haskell",
hs: "haskell",
elixir: "elixir",
ex: "elixir",
clj: "clojure",
cljs: "clojure",
// Infrastructure
dockerfile: "dockerfile",
docker: "docker",
tf: "terraform",
tfvars: "terraform",
hcl: "hcl",
// Build & Config
makefile: "makefile",
cmake: "cmake",
gradle: "groovy",
// Git
gitignore: "git-commit",
gitattributes: "git-commit",
// Misc
graphql: "graphql",
gql: "graphql",
proto: "protobuf",
prisma: "prisma",
wasm: "wasm",
zig: "zig",
v: "v",
};
export function getFileName(filepath: string) { export function getFileName(filepath: string) {
return filepath.split("/").pop()!; return filepath.split("/").pop()!;
} }
export function getFileExtension(filepath: string) { export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase();
}
export function checkCodeFile(
filepath: string,
):
| { isCodeFile: true; language: BundledLanguage }
| { isCodeFile: false; language: null } {
const extension = getFileExtension(filepath);
const isCodeFile = extension in extensionMap;
if (isCodeFile) {
return {
isCodeFile: true,
language: extensionMap[extension] as unknown as BundledLanguage,
};
}
return {
isCodeFile: false,
language: null,
};
}
export function getFileExtensionDisplayName(filepath: string) {
const fileName = getFileName(filepath); const fileName = getFileName(filepath);
const extension = fileName.split(".").pop()!.toLocaleLowerCase(); const extension = fileName.split(".").pop()!.toLocaleLowerCase();
switch (extension) { switch (extension) {