mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 04:14:46 +08:00
feat: support artifact preview
This commit is contained in:
@@ -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=["*"],
|
||||||
|
|||||||
@@ -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}"'})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
14
frontend/pnpm-lock.yaml
generated
14
frontend/pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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({
|
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 (
|
||||||
|
|||||||
20
frontend/src/core/artifacts/hooks.ts
Normal file
20
frontend/src/core/artifacts/hooks.ts
Normal 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 };
|
||||||
|
}
|
||||||
1
frontend/src/core/artifacts/index.ts
Normal file
1
frontend/src/core/artifacts/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./loader";
|
||||||
14
frontend/src/core/artifacts/loader.ts
Normal file
14
frontend/src/core/artifacts/loader.ts
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/core/artifacts/utils.ts
Normal file
11
frontend/src/core/artifacts/utils.ts
Normal 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" : ""}`;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user