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
app.add_middleware(
CORSMiddleware,
allow_origins=config.cors_origins,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -1,8 +1,9 @@
import mimetypes
import os
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
# Base directory for thread data (relative to backend/)
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
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}")
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.
Args:
@@ -69,7 +81,19 @@ async def get_artifact(thread_id: str, path: str) -> FileResponse:
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
return FileResponse(
path=actual_path,
filename=actual_path.name,
)
mime_type, _ = mimetypes.guess_type(actual_path)
# 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-resizable-panels": "^4.4.1",
"shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",

View File

@@ -113,6 +113,9 @@ importers:
shiki:
specifier: 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:
specifier: 1.4.0
version: 1.4.0(@types/react@19.2.8)(react@19.2.3)
@@ -4274,6 +4277,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -9287,6 +9296,11 @@ snapshots:
ufo: 1.6.2
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: {}
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 { useCallback, useEffect, useMemo, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
@@ -25,7 +26,6 @@ import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
export default function ChatPage() {
const router = useRouter();
@@ -81,7 +81,7 @@ export default function ChatPage() {
minSize={30}
>
<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">
<FlipDisplay
uniqueKey={title}
@@ -109,7 +109,11 @@ export default function ChatPage() {
</header>
<main className="flex min-h-0 grow flex-col">
<div className="flex size-full justify-center">
<MessageList className="size-full" thread={thread} />
<MessageList
className="size-full"
threadId={threadId!}
thread={thread}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 flex justify-center px-4">
<InputBox
@@ -139,17 +143,6 @@ export default function ChatPage() {
defaultSize={artifactsOpen ? 64 : 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
className={cn(
"h-full transition-transform duration-300 ease-in-out",
@@ -160,9 +153,21 @@ export default function ChatPage() {
<ArtifactFileDetail
className="size-full"
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
icon={<FilesIcon />}
title="No artifact selected"

View File

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

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) {
return filepath.split("/").pop()!;
}
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 extension = fileName.split(".").pop()!.toLocaleLowerCase();
switch (extension) {