diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index d895f9de58..9a68eb3805 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -21,7 +21,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { useConfig } from "#/hooks/query/use-config"; import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory"; -import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; +import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; import { OpenHandsAction } from "#/types/core/actions"; import { useEventStore } from "#/stores/use-event-store"; @@ -31,7 +31,7 @@ vi.mock("#/stores/error-message-store"); vi.mock("#/stores/optimistic-user-message-store"); vi.mock("#/hooks/query/use-config"); vi.mock("#/hooks/mutation/use-get-trajectory"); -vi.mock("#/hooks/mutation/use-upload-files"); +vi.mock("#/hooks/mutation/use-unified-upload-files"); // Mock React Router hooks at the top level vi.mock("react-router", async () => { @@ -128,7 +128,7 @@ describe("ChatInterface - Chat Suggestions", () => { mutateAsync: vi.fn(), isLoading: false, }); - (useUploadFiles as unknown as ReturnType).mockReturnValue({ + (useUnifiedUploadFiles as unknown as ReturnType).mockReturnValue({ mutateAsync: vi .fn() .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), @@ -267,7 +267,7 @@ describe("ChatInterface - Empty state", () => { mutateAsync: vi.fn(), isLoading: false, }); - (useUploadFiles as unknown as ReturnType).mockReturnValue({ + (useUnifiedUploadFiles as unknown as ReturnType).mockReturnValue({ mutateAsync: vi .fn() .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 935574f1a8..89860ec021 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -253,6 +253,44 @@ class V1ConversationService { ); return data; } + + /** + * Upload a single file to the V1 conversation workspace + * V1 API endpoint: POST /api/file/upload/{path} + * + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param sessionApiKey Session API key for authentication (required for V1) + * @param file The file to upload + * @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name}) + * @returns void on success, throws on error + */ + static async uploadFile( + conversationUrl: string | null | undefined, + sessionApiKey: string | null | undefined, + file: File, + path?: string, + ): Promise { + // Default to /workspace/{filename} if no path provided (must be absolute) + const uploadPath = path || `/workspace/${file.name}`; + const encodedPath = encodeURIComponent(uploadPath); + const url = this.buildRuntimeUrl( + conversationUrl, + `/api/file/upload/${encodedPath}`, + ); + const headers = this.buildSessionHeaders(sessionApiKey); + + // Create FormData with the file + const formData = new FormData(); + formData.append("file", file); + + // Upload file + await axios.post(url, formData, { + headers: { + ...headers, + "Content-Type": "multipart/form-data", + }, + }); + } } export default V1ConversationService; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 83a545f247..aac8e7b42d 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -35,7 +35,7 @@ import { hasUserEvent as hasV1UserEvent, shouldRenderEvent as shouldRenderV1Event, } from "#/components/v1/chat"; -import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; +import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; import { useConfig } from "#/hooks/query/use-config"; import { validateFiles } from "#/utils/file-validation"; import { useConversationStore } from "#/state/conversation-store"; @@ -86,7 +86,7 @@ export function ChatInterface() { const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false); const { selectedRepository, replayJson } = useInitialQueryStore(); const params = useParams(); - const { mutateAsync: uploadFiles } = useUploadFiles(); + const { mutateAsync: uploadFiles } = useUnifiedUploadFiles(); const optimisticUserMessage = getOptimisticUserMessage(); diff --git a/frontend/src/hooks/mutation/use-unified-upload-files.ts b/frontend/src/hooks/mutation/use-unified-upload-files.ts new file mode 100644 index 0000000000..84e9a4d876 --- /dev/null +++ b/frontend/src/hooks/mutation/use-unified-upload-files.ts @@ -0,0 +1,55 @@ +import { useMutation } from "@tanstack/react-query"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useUploadFiles } from "./use-upload-files"; +import { useV1UploadFiles } from "./use-v1-upload-files"; +import { FileUploadSuccessResponse } from "#/api/open-hands.types"; + +interface UnifiedUploadFilesVariables { + conversationId: string; + files: File[]; +} + +/** + * Unified hook that automatically selects the correct file upload method + * based on the conversation version (V0 or V1). + * + * For V0 conversations: Uses the legacy multi-file upload endpoint + * For V1 conversations: Uses parallel single-file uploads + * + * @returns Mutation hook with the same interface as useUploadFiles + */ +export const useUnifiedUploadFiles = () => { + const { data: conversation } = useActiveConversation(); + const isV1Conversation = conversation?.conversation_version === "V1"; + + // Initialize both hooks + const v0Upload = useUploadFiles(); + const v1Upload = useV1UploadFiles(); + + // Create a unified mutation that delegates to the appropriate hook + return useMutation({ + mutationKey: ["unified-upload-files"], + mutationFn: async ( + variables: UnifiedUploadFilesVariables, + ): Promise => { + const { conversationId, files } = variables; + + if (isV1Conversation) { + // V1: Use conversation URL and session API key + return v1Upload.mutateAsync({ + conversationUrl: conversation?.url, + sessionApiKey: conversation?.session_api_key, + files, + }); + } + // V0: Use conversation ID + return v0Upload.mutateAsync({ + conversationId, + files, + }); + }, + meta: { + disableToast: true, + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-v1-upload-files.ts b/frontend/src/hooks/mutation/use-v1-upload-files.ts new file mode 100644 index 0000000000..564dde4479 --- /dev/null +++ b/frontend/src/hooks/mutation/use-v1-upload-files.ts @@ -0,0 +1,82 @@ +import { useMutation } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; +import { FileUploadSuccessResponse } from "#/api/open-hands.types"; + +interface V1UploadFilesVariables { + conversationUrl: string | null | undefined; + sessionApiKey: string | null | undefined; + files: File[]; +} + +/** + * Hook to upload multiple files in parallel to V1 conversations + * Uploads files concurrently using Promise.allSettled and aggregates results + * + * @returns Mutation hook with mutateAsync function + */ +export const useV1UploadFiles = () => + useMutation({ + mutationKey: ["v1-upload-files"], + mutationFn: async ( + variables: V1UploadFilesVariables, + ): Promise => { + const { conversationUrl, sessionApiKey, files } = variables; + + // Upload all files in parallel + const uploadPromises = files.map(async (file) => { + try { + // Upload to /workspace/{filename} + const filePath = `/workspace/${file.name}`; + await V1ConversationService.uploadFile( + conversationUrl, + sessionApiKey, + file, + filePath, + ); + return { success: true as const, fileName: file.name, filePath }; + } catch (error) { + return { + success: false as const, + fileName: file.name, + filePath: `/workspace/${file.name}`, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + // Wait for all uploads to complete (both successful and failed) + const results = await Promise.allSettled(uploadPromises); + + // Aggregate the results + const uploadedFiles: string[] = []; + const skippedFiles: { name: string; reason: string }[] = []; + + results.forEach((result) => { + if (result.status === "fulfilled") { + if (result.value.success) { + // Return the absolute file path for V1 + uploadedFiles.push(result.value.filePath); + } else { + skippedFiles.push({ + name: result.value.fileName, + reason: result.value.error, + }); + } + } else { + // Promise was rejected (shouldn't happen since we catch errors above) + skippedFiles.push({ + name: "unknown", + reason: result.reason?.message || "Upload failed", + }); + } + }); + + return { + uploaded_files: uploadedFiles, + skipped_files: skippedFiles, + }; + }, + meta: { + disableToast: true, + }, + });