Add unified file upload support for V0 and V1 conversations (#11457)

This commit is contained in:
sp.wack 2025-10-22 17:44:38 +04:00 committed by GitHub
parent a5c5133961
commit 6a5b915088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 181 additions and 6 deletions

View File

@ -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: [] }),

View File

@ -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;

View File

@ -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();

View 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,
},
});
};

View 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,
},
});