diff --git a/backend/src/gateway/routers/uploads.py b/backend/src/gateway/routers/uploads.py index 0fc0991a..311251d6 100644 --- a/backend/src/gateway/routers/uploads.py +++ b/backend/src/gateway/routers/uploads.py @@ -2,9 +2,10 @@ import logging import os +import shutil from pathlib import Path -from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi import APIRouter, File, Form, HTTPException, UploadFile from pydantic import BaseModel from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR @@ -14,6 +15,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"]) +SKILL_UPLOAD_TARGET = "skill" + # File extensions that should be converted to markdown CONVERTIBLE_EXTENSIONS = { ".pdf", @@ -78,6 +81,7 @@ async def convert_file_to_markdown(file_path: Path) -> Path | None: async def upload_files( thread_id: str, files: list[UploadFile] = File(...), + upload_target: str | None = Form(default=None), ) -> UploadResponse: """Upload multiple files to a thread's uploads directory. @@ -95,6 +99,19 @@ async def upload_files( raise HTTPException(status_code=400, detail="No files provided") uploads_dir = get_uploads_dir(thread_id) + target_dir = uploads_dir + relative_upload_prefix = "uploads" + + if upload_target == SKILL_UPLOAD_TARGET: + target_dir = uploads_dir / SKILL_UPLOAD_TARGET + + # Clean existing uploads/skill directory to keep only the latest skill package + if target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + + relative_upload_prefix = f"uploads/{SKILL_UPLOAD_TARGET}" + uploaded_files = [] sandbox_provider = get_sandbox_provider() @@ -107,12 +124,12 @@ async def upload_files( try: # Save the original file - file_path = uploads_dir / file.filename + file_path = target_dir / file.filename content = await file.read() # Build relative path from backend root - relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file.filename}" - virtual_path = f"/mnt/user-data/uploads/{file.filename}" + relative_path = f".deer-flow/threads/{thread_id}/user-data/{relative_upload_prefix}/{file.filename}" + virtual_path = f"/mnt/user-data/{relative_upload_prefix}/{file.filename}" sandbox.update_file(virtual_path, content) file_info = { @@ -120,7 +137,7 @@ async def upload_files( "size": str(len(content)), "path": relative_path, # Actual filesystem path (relative to backend/) "virtual_path": virtual_path, # Path for Agent in sandbox - "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file.filename}", # HTTP URL + "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/{relative_upload_prefix}/{file.filename}", # HTTP URL } logger.info(f"Saved file: {file.filename} ({len(content)} bytes) to {relative_path}") @@ -130,11 +147,13 @@ async def upload_files( if file_ext in CONVERTIBLE_EXTENSIONS: md_path = await convert_file_to_markdown(file_path) if md_path: - md_relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{md_path.name}" + md_relative_path = f".deer-flow/threads/{thread_id}/user-data/{relative_upload_prefix}/{md_path.name}" file_info["markdown_file"] = md_path.name file_info["markdown_path"] = md_relative_path - file_info["markdown_virtual_path"] = f"/mnt/user-data/uploads/{md_path.name}" - file_info["markdown_artifact_url"] = f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{md_path.name}" + file_info["markdown_virtual_path"] = f"/mnt/user-data/{relative_upload_prefix}/{md_path.name}" + file_info["markdown_artifact_url"] = ( + f"/api/threads/{thread_id}/artifacts/mnt/user-data/{relative_upload_prefix}/{md_path.name}" + ) uploaded_files.append(file_info) @@ -165,17 +184,18 @@ async def list_uploaded_files(thread_id: str) -> dict: return {"files": [], "count": 0} files = [] - for file_path in sorted(uploads_dir.iterdir()): + for file_path in sorted(uploads_dir.rglob("*")): if file_path.is_file(): stat = file_path.stat() - relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file_path.name}" + path_in_uploads = file_path.relative_to(uploads_dir).as_posix() + relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{path_in_uploads}" files.append( { - "filename": file_path.name, + "filename": path_in_uploads, "size": stat.st_size, "path": relative_path, # Actual filesystem path (relative to backend/) - "virtual_path": f"/mnt/user-data/uploads/{file_path.name}", # Path for Agent in sandbox - "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file_path.name}", # HTTP URL + "virtual_path": f"/mnt/user-data/uploads/{path_in_uploads}", # Path for Agent in sandbox + "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{path_in_uploads}", # HTTP URL "extension": file_path.suffix, "modified": stat.st_mtime, } diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index fb66c382..c4740f15 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -81,17 +81,34 @@ export default function ChatPage() { } }, [inputInitialValue]); const isNewThread = useMemo( - () => threadIdFromPath === "new", - [threadIdFromPath], + () => { + if (threadIdFromPath !== "new") { + return false; + } + + const queryThreadId = searchParams.get("thread_id")?.trim(); + const queryIsNew = searchParams.get("isnew")?.trim().toLowerCase(); + const shouldReuseExisting = queryIsNew === "false" && !!queryThreadId; + + return !shouldReuseExisting; + }, + [threadIdFromPath, searchParams], ); + + const uploadTarget = useMemo(() => { + const target = searchParams.get("upload_target")?.trim().toLowerCase(); + return target === "skill" ? "skill" : undefined; + }, [searchParams]); + const [threadId, setThreadId] = useState(null); useEffect(() => { if (threadIdFromPath !== "new") { setThreadId(threadIdFromPath); } else { - setThreadId(uuid()); + const queryThreadId = searchParams.get("thread_id")?.trim(); + setThreadId(queryThreadId || uuid()); } - }, [threadIdFromPath]); + }, [threadIdFromPath, searchParams]); const { showNotification } = useNotification(); const [finalState, setFinalState] = useState(null); @@ -185,6 +202,7 @@ export default function ChatPage() { isNewThread, threadId, thread, + uploadTarget, threadContext: { ...settings.context, thinking_enabled: settings.context.mode !== "flash", diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 078b9a6b..fab29800 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -10,6 +10,7 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; import { useUpdateSubtask } from "../tasks/context"; import { uploadFiles } from "../uploads"; +import type { UploadTarget } from "../uploads/api"; import type { AgentThread, @@ -83,12 +84,14 @@ export function useSubmitThread({ thread, threadContext, isNewThread, + uploadTarget, afterSubmit, }: { isNewThread: boolean; threadId: string | null | undefined; thread: UseStream; threadContext: Omit; + uploadTarget?: UploadTarget; afterSubmit?: () => void; }) { const queryClient = useQueryClient(); @@ -127,7 +130,7 @@ export function useSubmitThread({ ); if (files.length > 0 && threadId) { - await uploadFiles(threadId, files); + await uploadFiles(threadId, files, { target: uploadTarget }); } } catch (error) { console.error("Failed to upload files:", error); @@ -167,7 +170,7 @@ export function useSubmitThread({ void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); afterSubmit?.(); }, - [thread, isNewThread, threadId, threadContext, queryClient, afterSubmit], + [thread, isNewThread, threadId, threadContext, uploadTarget, queryClient, afterSubmit], ); return callback; } diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 35725515..5622a053 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -29,12 +29,15 @@ export interface ListFilesResponse { count: number; } +export type UploadTarget = "skill"; + /** * Upload files to a thread */ export async function uploadFiles( threadId: string, files: File[], + options?: { target?: UploadTarget }, ): Promise { const formData = new FormData(); @@ -42,6 +45,10 @@ export async function uploadFiles( formData.append("files", file); }); + if (options?.target) { + formData.append("upload_target", options.target); + } + const response = await fetch( `${getBackendBaseURL()}/api/threads/${threadId}/uploads`, {