feat(chat): support file upload (#8945)

Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Mislav Lukach 2025-06-18 18:13:07 +02:00 committed by GitHub
parent a92d6904fc
commit a9f26a13a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 312 additions and 203 deletions

View File

@ -193,9 +193,9 @@ describe("ChatInput", () => {
it("should handle image paste correctly", () => {
const onSubmit = vi.fn();
const onImagePaste = vi.fn();
const onFilesPaste = vi.fn();
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
render(<ChatInput onSubmit={onSubmit} onFilesPaste={onFilesPaste} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
@ -213,8 +213,8 @@ describe("ChatInput", () => {
},
});
// Verify image paste was handled
expect(onImagePaste).toHaveBeenCalledWith([file]);
// Verify file paste was handled
expect(onFilesPaste).toHaveBeenCalledWith([file]);
});
it("should use the default maxRows value", () => {

View File

@ -1,4 +1,4 @@
import { render, screen, within, fireEvent } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
@ -92,7 +92,7 @@ describe("InteractiveChatBox", () => {
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]);
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file], []);
// clear images after submission
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
@ -144,7 +144,7 @@ describe("InteractiveChatBox", () => {
onStop={onStop}
onChange={onChange}
value="test message"
/>
/>,
);
// Upload an image via the upload button - this should NOT clear the text input
@ -161,7 +161,7 @@ describe("InteractiveChatBox", () => {
await user.click(submitButton);
// Verify onSubmit was called with the message and image
expect(onSubmit).toHaveBeenCalledWith("test message", [file]);
expect(onSubmit).toHaveBeenCalledWith("test message", [file], []);
// Verify onChange was called to clear the text input
expect(onChange).toHaveBeenCalledWith("");
@ -173,7 +173,7 @@ describe("InteractiveChatBox", () => {
onStop={onStop}
onChange={onChange}
value=""
/>
/>,
);
// Verify the text input was cleared

View File

@ -41,19 +41,6 @@ describe("UploadImageInput", () => {
expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
});
it("should not upload any file that is not an image", async () => {
render(<UploadImageInput onUpload={onUploadMock} />);
const file = new File(["(⌐□_□)"], "chucknorris.txt", {
type: "text/plain",
});
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
expect(onUploadMock).not.toHaveBeenCalled();
});
it("should render custom labels", () => {
const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
expect(screen.getByTestId("default-label")).toBeInTheDocument();

View File

@ -1,6 +1,7 @@
import { openHands } from "../open-hands-axios";
import { GetFilesResponse, GetFileResponse } from "./file-service.types";
import { getConversationUrl } from "../conversation.utils";
import { FileUploadSuccessResponse } from "../open-hands.types";
export class FileService {
/**
@ -35,4 +36,31 @@ export class FileService {
return data.code;
}
/**
* Upload multiple files to the workspace
* @param conversationId ID of the conversation
* @param files List of files.
* @returns list of uploaded files, list of skipped files
*/
static async uploadFiles(
conversationId: string,
files: File[],
): Promise<FileUploadSuccessResponse> {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const url = `${getConversationUrl(conversationId)}/upload-files`;
const response = await openHands.post<FileUploadSuccessResponse>(
url,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return response.data;
}
}

View File

@ -10,7 +10,6 @@ export interface SaveFileSuccessResponse {
}
export interface FileUploadSuccessResponse {
message: string;
uploaded_files: string[];
skipped_files: { name: string; reason: string }[];
}

View File

@ -18,7 +18,7 @@ interface ChatInputProps {
onChange?: (message: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onImagePaste?: (files: File[]) => void;
onFilesPaste?: (files: File[]) => void;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
}
@ -35,7 +35,7 @@ export function ChatInput({
onChange,
onFocus,
onBlur,
onImagePaste,
onFilesPaste,
className,
buttonClassName,
}: ChatInputProps) {
@ -45,15 +45,11 @@ export function ChatInput({
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Only handle paste if we have an image paste handler and there are files
if (onImagePaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"),
);
if (onFilesPaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files);
// Only prevent default if we found image files to handle
if (files.length > 0) {
event.preventDefault();
onImagePaste(files);
}
event.preventDefault();
onFilesPaste(files);
}
// For text paste, let the default behavior handle it
};
@ -73,12 +69,10 @@ export function ChatInput({
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setIsDraggingOver(false);
if (onImagePaste && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files).filter((file) =>
file.type.startsWith("image/"),
);
if (onFilesPaste && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
onImagePaste(files);
onFilesPaste(files);
}
}
};

View File

@ -29,6 +29,7 @@ import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
import { ErrorMessageBanner } from "./error-message-banner";
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
function getEntryPoint(
@ -69,13 +70,18 @@ export function ChatInterface() {
);
const params = useParams();
const { mutate: getTrajectory } = useGetTrajectory();
const { mutateAsync: uploadFiles } = useUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
const errorMessage = getErrorMessage();
const events = parsedEvents.filter(shouldRenderEvent);
const handleSendMessage = async (content: string, files: File[]) => {
const handleSendMessage = async (
content: string,
images: File[],
files: File[],
) => {
if (events.length === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
@ -91,11 +97,23 @@ export function ChatInterface() {
current_message_length: content.length,
});
}
const promises = files.map((file) => convertImageToBase64(file));
const promises = images.map((image) => convertImageToBase64(image));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
send(createChatMessage(content, imageUrls, timestamp));
const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
files.length > 0
? await uploadFiles({ conversationId: params.conversationId!, files })
: { skipped_files: [], uploaded_files: [] };
skippedFiles.forEach((f) => displayErrorToast(f.reason));
const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`;
const prompt =
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
setOptimisticUserMessage(content);
setMessageToSend(null);
};
@ -177,7 +195,7 @@ export function ChatInterface() {
events.length > 0 &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [])}
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
/>
)}
</div>

View File

@ -0,0 +1,19 @@
import {
AssistantMessageAction,
UserMessageAction,
} from "#/types/core/actions";
import i18n from "#/i18n";
import { isUserMessage } from "#/types/core/guards";
export const parseMessageFromEvent = (
event: UserMessageAction | AssistantMessageAction,
): string => {
const m = isUserMessage(event) ? event.args.content : event.message;
if (!event.args.file_urls || event.args.file_urls.length === 0) {
return m;
}
const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
const parts = m.split(delimiter);
return parts[0];
};

View File

@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
import { getObservationResult } from "./event-content-helpers/get-observation-result";
import { getEventContent } from "./event-content-helpers/get-event-content";
import { GenericEventMessage } from "./generic-event-message";
import { FileList } from "../files/file-list";
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
import { LikertScale } from "../feedback/likert-scale";
import { useConfig } from "#/hooks/query/use-config";
@ -91,14 +93,16 @@ export function EventMessage({
}
if (isUserMessage(event) || isAssistantMessage(event)) {
const message = parseMessageFromEvent(event);
return (
<ChatMessage
type={event.source}
message={isUserMessage(event) ? event.args.content : event.message}
>
<ChatMessage type={event.source} message={message}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
)}
{event.args.file_urls && event.args.file_urls.length > 0 && (
<FileList files={event.args.file_urls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
);

View File

@ -3,11 +3,13 @@ import { ChatInput } from "./chat-input";
import { cn } from "#/utils/utils";
import { ImageCarousel } from "../images/image-carousel";
import { UploadImageInput } from "../images/upload-image-input";
import { FileList } from "../files/file-list";
import { isFileImage } from "#/utils/is-file-image";
interface InteractiveChatBoxProps {
isDisabled?: boolean;
mode?: "stop" | "submit";
onSubmit: (message: string, images: File[]) => void;
onSubmit: (message: string, images: File[], files: File[]) => void;
onStop: () => void;
value?: string;
onChange?: (message: string) => void;
@ -22,21 +24,35 @@ export function InteractiveChatBox({
onChange,
}: InteractiveChatBoxProps) {
const [images, setImages] = React.useState<File[]>([]);
const [files, setFiles] = React.useState<File[]>([]);
const handleUpload = (files: File[]) => {
setImages((prevImages) => [...prevImages, ...files]);
const handleUpload = (selectedFiles: File[]) => {
setFiles((prevFiles) => [
...prevFiles,
...selectedFiles.filter((f) => !isFileImage(f)),
]);
setImages((prevImages) => [
...prevImages,
...selectedFiles.filter((f) => isFileImage(f)),
]);
};
const removeElementByIndex = (array: Array<File>, index: number) => {
const newArray = [...array];
newArray.splice(index, 1);
return newArray;
};
const handleRemoveFile = (index: number) => {
setFiles(removeElementByIndex(files, index));
};
const handleRemoveImage = (index: number) => {
setImages((prevImages) => {
const newImages = [...prevImages];
newImages.splice(index, 1);
return newImages;
});
setImages(removeElementByIndex(images, index));
};
const handleSubmit = (message: string) => {
onSubmit(message, images);
onSubmit(message, images, files);
setFiles([]);
setImages([]);
if (message) {
onChange?.("");
@ -55,6 +71,12 @@ export function InteractiveChatBox({
onRemove={handleRemoveImage}
/>
)}
{files.length > 0 && (
<FileList
files={files.map((f) => f.name)}
onRemove={handleRemoveFile}
/>
)}
<div
className={cn(
@ -72,7 +94,7 @@ export function InteractiveChatBox({
onSubmit={handleSubmit}
onStop={onStop}
value={value}
onImagePaste={handleUpload}
onFilesPaste={handleUpload}
className="py-[10px]"
buttonClassName="py-[10px]"
/>

View File

@ -0,0 +1,20 @@
import { FaFile } from "react-icons/fa";
import { RemoveButton } from "#/components/shared/buttons/remove-button";
interface FileItemProps {
filename: string;
onRemove?: () => void;
}
export function FileItem({ filename, onRemove }: FileItemProps) {
return (
<div
data-testid="file-item"
className="flex flex-row gap-x-1 items-center justify-start"
>
<FaFile className="h-4 w-4" />
<code className="text-sm flex-1 text-white truncate">{filename}</code>
{onRemove && <RemoveButton onClick={onRemove} />}
</div>
);
}

View File

@ -0,0 +1,25 @@
import React from "react";
import { cn } from "#/utils/utils";
import { FileItem } from "./file-item";
interface FileListProps {
files: string[];
onRemove?: (index: number) => void;
}
export function FileList({ files, onRemove }: FileListProps) {
return (
<div
data-testid="file-list"
className={cn("flex flex-col gap-y-1.5 justify-start")}
>
{files.map((f, index) => (
<FileItem
key={index}
filename={f}
onRemove={onRemove ? () => onRemove?.(index) : undefined}
/>
))}
</div>
);
}

View File

@ -60,7 +60,7 @@ export function ImageCarousel({
key={index}
size={size}
src={src}
onRemove={onRemove && (() => onRemove(index))}
onRemove={onRemove ? () => onRemove?.(index) : undefined}
/>
))}
</div>

View File

@ -15,7 +15,12 @@ export function ImagePreview({
return (
<div data-testid="image-preview" className="relative w-fit shrink-0">
<Thumbnail src={src} size={size} />
{onRemove && <RemoveButton onClick={onRemove} />}
{onRemove && (
<RemoveButton
onClick={onRemove}
className="absolute right-[3px] top-[3px]"
/>
)}
</div>
);
}

View File

@ -8,10 +8,7 @@ interface UploadImageInputProps {
export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const validFiles = Array.from(event.target.files).filter((file) =>
file.type.startsWith("image/"),
);
onUpload(validFiles);
onUpload(Array.from(event.target.files));
}
};
@ -21,7 +18,6 @@ export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
<input
data-testid="upload-image-input"
type="file"
accept="image/*"
multiple
hidden
onChange={handleUpload}

View File

@ -3,19 +3,20 @@ import CloseIcon from "#/icons/close.svg?react";
interface RemoveButtonProps {
onClick: () => void;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
}
export function RemoveButton({ onClick }: RemoveButtonProps) {
export function RemoveButton({ onClick, className }: RemoveButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
"absolute right-[3px] top-[3px]",
"bg-neutral-400 rounded-full w-5 h-5 flex items-center justify-center",
className,
)}
>
<CloseIcon width={10} height={10} />
<CloseIcon width={18} height={18} />
</button>
);
}

View File

@ -1,126 +0,0 @@
import React from "react";
import { useNavigation } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "#/store";
import { addFile, removeFile } from "#/state/initial-query-slice";
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatInput } from "#/components/features/chat/chat-input";
import { getRandomKey } from "#/utils/get-random-key";
import { cn } from "#/utils/utils";
import { AttachImageLabel } from "../features/images/attach-image-label";
import { ImageCarousel } from "../features/images/image-carousel";
import { UploadImageInput } from "../features/images/upload-image-input";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { LoadingSpinner } from "./loading-spinner";
interface TaskFormProps {
ref: React.RefObject<HTMLFormElement | null>;
}
export function TaskForm({ ref }: TaskFormProps) {
const dispatch = useDispatch();
const navigation = useNavigation();
const { files } = useSelector((state: RootState) => state.initialQuery);
const [text, setText] = React.useState("");
const [suggestion, setSuggestion] = React.useState(() => {
const key = getRandomKey(SUGGESTIONS["non-repo"]);
return { key, value: SUGGESTIONS["non-repo"][key] };
});
const [inputIsFocused, setInputIsFocused] = React.useState(false);
const { mutate: createConversation, isPending } = useCreateConversation();
const onRefreshSuggestion = () => {
const suggestions = SUGGESTIONS["non-repo"];
// remove current suggestion to avoid refreshing to the same suggestion
const suggestionCopy = { ...suggestions };
delete suggestionCopy[suggestion.key];
const key = getRandomKey(suggestionCopy);
setSuggestion({ key, value: suggestions[key] });
};
const onClickSuggestion = () => {
setText(suggestion.value);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
createConversation({ q });
};
return (
<div className="flex flex-col gap-1 w-full">
<form
ref={ref}
onSubmit={handleSubmit}
className="flex flex-col items-center gap-2"
>
<SuggestionBubble
suggestion={suggestion}
onClick={onClickSuggestion}
onRefresh={onRefreshSuggestion}
/>
<div
className={cn(
"border border-neutral-600 px-4 rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
inputIsFocused ? "bg-neutral-600" : "bg-tertiary",
"hover:border-neutral-500 focus-within:border-neutral-500",
)}
>
{isPending ? (
<div className="flex justify-center py-[17px]">
<LoadingSpinner size="small" />
</div>
) : (
<ChatInput
name="q"
onSubmit={() => {
if (typeof ref !== "function") ref?.current?.requestSubmit();
}}
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={navigation.state === "submitting"}
/>
)}
</div>
</form>
<UploadImageInput
onUpload={async (uploadedFiles) => {
const promises = uploadedFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
label={<AttachImageLabel />}
/>
{files.length > 0 && (
<ImageCarousel
size="large"
images={files}
onRemove={(index) => dispatch(removeFile(index))}
/>
)}
</div>
);
}

View File

@ -0,0 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import { FileService } from "#/api/file-service/file-service.api";
export const useUploadFiles = () =>
useMutation({
mutationKey: ["upload-files"],
mutationFn: (variables: { conversationId: string; files: File[] }) =>
FileService.uploadFiles(variables.conversationId!, variables.files),
onSuccess: async () => {},
meta: {
disableToast: true,
},
});

View File

@ -248,6 +248,7 @@ export enum I18nKey {
INVARIANT$TRACE_EXPORTED_MESSAGE = "INVARIANT$TRACE_EXPORTED_MESSAGE",
INVARIANT$POLICY_UPDATED_MESSAGE = "INVARIANT$POLICY_UPDATED_MESSAGE",
INVARIANT$SETTINGS_UPDATED_MESSAGE = "INVARIANT$SETTINGS_UPDATED_MESSAGE",
CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE = "CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE",
CHAT_INTERFACE$DISCONNECTED = "CHAT_INTERFACE$DISCONNECTED",
CHAT_INTERFACE$CONNECTING = "CHAT_INTERFACE$CONNECTING",
CHAT_INTERFACE$STOPPED = "CHAT_INTERFACE$STOPPED",

View File

@ -3967,6 +3967,22 @@
"ja": "設定を更新しました",
"uk": "Налаштування оновлено"
},
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE":{
"en": "NEW FILES ADDED",
"de": "NEUE DATEIEN HINZUGEFÜGT",
"zh-CN": "已添加新文件",
"zh-TW": "已新增檔案",
"ko-KR": "새 파일이 추가되었습니다",
"no": "NYE FILER LAGT TIL",
"it": "NUOVI FILE AGGIUNTI",
"pt": "NOVOS ARQUIVOS ADICIONADOS",
"es": "NUEVOS ARCHIVOS AÑADIDOS",
"ar": "تمت إضافة ملفات جديدة",
"fr": "NOUVEAUX FICHIERS AJOUTÉS",
"tr": "YENİ DOSYALAR EKLENDİ",
"ja": "新しいファイルが追加されました",
"uk": "ДОДАНО НОВІ ФАЙЛИ"
},
"CHAT_INTERFACE$DISCONNECTED": {
"en": "Disconnected",
"ja": "切断されました",

View File

@ -31,6 +31,7 @@ export const generateAssistantMessageAction = (
args: {
thought: message,
image_urls: [],
file_urls: [],
wait_for_response: false,
},
});
@ -46,6 +47,7 @@ export const generateUserMessageAction = (
args: {
content: message,
image_urls: [],
file_urls: [],
},
});

View File

@ -3,11 +3,12 @@ import ActionType from "#/types/action-type";
export function createChatMessage(
message: string,
image_urls: string[],
file_urls: string[],
timestamp: string,
) {
const event = {
action: ActionType.MESSAGE,
args: { content: message, image_urls, timestamp },
args: { content: message, image_urls, file_urls, timestamp },
};
return event;
}

View File

@ -6,6 +6,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
args: {
content: string;
image_urls: string[];
file_urls: string[];
};
}
@ -36,6 +37,7 @@ export interface AssistantMessageAction
args: {
thought: string;
image_urls: string[] | null;
file_urls: string[];
wait_for_response: boolean;
};
}

View File

@ -0,0 +1,7 @@
/**
* Check if a file is an image.
* @param file - The File object to check.
* @returns True if the file is an image, false otherwise.
*/
export const isFileImage = (file: File): boolean =>
file.type.startsWith("image/");

View File

@ -9,6 +9,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
@dataclass
class MessageAction(Action):
content: str
file_urls: list[str] | None = None
image_urls: list[str] | None = None
wait_for_response: bool = False
action: str = ActionType.MESSAGE
@ -33,6 +34,9 @@ class MessageAction(Action):
if self.image_urls:
for url in self.image_urls:
ret += f'\nIMAGE_URL: {url}'
if self.file_urls:
for url in self.file_urls:
ret += f'\nFILE_URL: {url}'
return ret

View File

@ -111,3 +111,29 @@ def is_extension_allowed(filename: str) -> bool:
or file_ext in (ext.lower() for ext in ALLOWED_EXTENSIONS)
or (file_ext == '' and '.' in ALLOWED_EXTENSIONS)
)
def get_unique_filename(filename: str, folder_path: str) -> str:
"""Returns unique filename on given folder_path. By checking if the given
filename exists. If it doesn't, filename is simply returned.
Otherwise, it append copy(#number) until the filename is unique.
Args:
filename (str): The name of the file to check.
folder_path (str): directory path in which file name check is performed.
Returns:
string: unique filename.
"""
name, ext = os.path.splitext(filename)
filename_candidate = filename
copy_index = 0
while os.path.exists(os.path.join(folder_path, filename_candidate)):
if copy_index == 0:
filename_candidate = f'{name} copy{ext}'
else:
filename_candidate = f'{name} copy({copy_index}){ext}'
copy_index += 1
return filename_candidate

12
openhands/server/files.py Normal file
View File

@ -0,0 +1,12 @@
from pydantic import (
BaseModel,
)
class POSTUploadFilesModel(BaseModel):
"""
Upload files response model
"""
file_urls: list[str]
skipped_files: list[str]

View File

@ -1,12 +1,7 @@
import os
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
status,
)
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, JSONResponse
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
@ -17,15 +12,15 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
FileReadAction,
)
from openhands.events.action.files import FileWriteAction
from openhands.events.observation import (
ErrorObservation,
FileReadObservation,
)
from openhands.runtime.base import Runtime
from openhands.server.dependencies import get_dependencies
from openhands.server.file_config import (
FILES_TO_IGNORE,
)
from openhands.server.file_config import FILES_TO_IGNORE
from openhands.server.files import POSTUploadFilesModel
from openhands.server.session.conversation import ServerConversation
from openhands.server.user_auth import get_user_id
from openhands.server.utils import get_conversation, get_conversation_store
@ -309,3 +304,37 @@ async def get_cwd(
cwd = os.path.join(cwd, repo_dir)
return cwd
@app.post('/upload-files', response_model=POSTUploadFilesModel)
async def upload_files(
files: list[UploadFile],
conversation: ServerConversation = Depends(get_conversation),
):
uploaded_files = []
skipped_files = []
runtime: Runtime = conversation.runtime
for file in files:
file_path = os.path.join(
runtime.config.workspace_mount_path_in_sandbox, str(file.filename)
)
try:
file_content = await file.read()
write_action = FileWriteAction(
# TODO: DISCUSS UTF8 encoding here
path=file_path,
content=file_content.decode('utf-8', errors='replace'),
)
# TODO: DISCUSS file name unique issues
await call_sync_from_async(runtime.run_action, write_action)
uploaded_files.append(file_path)
except Exception as e:
skipped_files.append({'name': file.filename, 'reason': str(e)})
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'uploaded_files': uploaded_files,
'skipped_files': skipped_files,
},
)

View File

@ -49,6 +49,7 @@ def test_event_props_serialization_deserialization():
'args': {
'content': 'This is a test.',
'image_urls': None,
'file_urls': None,
'wait_for_response': False,
},
}
@ -61,6 +62,7 @@ def test_message_action_serialization_deserialization():
'args': {
'content': 'This is a test.',
'image_urls': None,
'file_urls': None,
'wait_for_response': False,
},
}

View File

@ -18,6 +18,7 @@ def test_event_serialization_deserialization():
'args': {
'content': 'This is a test.',
'image_urls': None,
'file_urls': None,
'wait_for_response': False,
},
}
@ -39,6 +40,7 @@ def test_array_serialization_deserialization():
'args': {
'content': 'This is a test.',
'image_urls': None,
'file_urls': None,
'wait_for_response': False,
},
}