diff --git a/backend/src/gateway/app.py b/backend/src/gateway/app.py index f251f35..41d0aa3 100644 --- a/backend/src/gateway/app.py +++ b/backend/src/gateway/app.py @@ -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=["*"], diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index 9dbae10..452c8cf 100644 --- a/backend/src/gateway/routers/artifacts.py +++ b/backend/src/gateway/routers/artifacts.py @@ -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}"'}) diff --git a/frontend/package.json b/frontend/package.json index 7fff1ea..0b24b04 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3d0e597..e39d0b0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {} diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 99a40ed..3234a59 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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} >
-
+
- +
-
- -
) : ( -
+
+
+ +
} title="No artifact selected" diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 04e5960..9243e01 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -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({ {children} + ); } diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..9b20afe --- /dev/null +++ b/frontend/src/components/ui/sonner.tsx @@ -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 ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ) +} + +export { Toaster } diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 72bedb5..2fc8e87 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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 ( -
-
+ +
- + {getFileName(filepath)} + + {getFileExtensionDisplayName(filepath)} file +
-
{filepath}
-
-
+
+ + {isCodeFile && ( + { + 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" + /> + )} + + console.log("Download")} + tooltip="Download file" + /> + + setOpen(false)} + tooltip="Close" + /> + +
+ + + + + ); } diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx index 44309cb..0730eac 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx @@ -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({ > {getFileName(file)} - {getFileExtension(file)} file + + {getFileExtensionDisplayName(file)} file + - + e.stopPropagation()} + > + + diff --git a/frontend/src/components/workspace/artifacts/file-viewer.tsx b/frontend/src/components/workspace/artifacts/file-viewer.tsx new file mode 100644 index 0000000..97604f7 --- /dev/null +++ b/frontend/src/components/workspace/artifacts/file-viewer.tsx @@ -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 ( + + ); + } + return ( +
+ +
+ ); +} + +function CodeFileView({ + language, + filepath, + threadId, +}: { + language: BundledLanguage; + filepath: string; + threadId: string; +}) { + const { content: code } = useArtifactContent({ + filepath, + threadId, + }); + if (code) { + return ( + + ); + } +} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 7bc16b2..1c0354f 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -22,9 +22,11 @@ import { MessageListSkeleton } from "./skeleton"; export function MessageList({ className, + threadId, thread, }: { className?: string; + threadId: string; thread: UseStream; }) { if (thread.isThreadLoading) { @@ -57,7 +59,11 @@ export function MessageList({ } } return ( - + ); } return ( diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts new file mode 100644 index 0000000..e7a6cd4 --- /dev/null +++ b/frontend/src/core/artifacts/hooks.ts @@ -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 }; +} diff --git a/frontend/src/core/artifacts/index.ts b/frontend/src/core/artifacts/index.ts new file mode 100644 index 0000000..ee5286f --- /dev/null +++ b/frontend/src/core/artifacts/index.ts @@ -0,0 +1 @@ +export * from "./loader"; diff --git a/frontend/src/core/artifacts/loader.ts b/frontend/src/core/artifacts/loader.ts new file mode 100644 index 0000000..d55b1b4 --- /dev/null +++ b/frontend/src/core/artifacts/loader.ts @@ -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; +} diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts new file mode 100644 index 0000000..1975522 --- /dev/null +++ b/frontend/src/core/artifacts/utils.ts @@ -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" : ""}`; +} diff --git a/frontend/src/core/utils/files.ts b/frontend/src/core/utils/files.ts index ec65b10..f3923d0 100644 --- a/frontend/src/core/utils/files.ts +++ b/frontend/src/core/utils/files.ts @@ -1,8 +1,159 @@ +import type { BundledLanguage } from "shiki"; + +const extensionMap: Record = { + // 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) {