mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add unified file upload support for V0 and V1 conversations (#11457)
This commit is contained in:
parent
a5c5133961
commit
6a5b915088
@ -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<typeof vi.fn>).mockReturnValue({
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
|
||||
@ -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<void> {
|
||||
// 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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
55
frontend/src/hooks/mutation/use-unified-upload-files.ts
Normal file
55
frontend/src/hooks/mutation/use-unified-upload-files.ts
Normal file
@ -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<FileUploadSuccessResponse> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
82
frontend/src/hooks/mutation/use-v1-upload-files.ts
Normal file
82
frontend/src/hooks/mutation/use-v1-upload-files.ts
Normal file
@ -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<FileUploadSuccessResponse> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user