diff --git a/frontend/__tests__/components/chat/chat-input.test.tsx b/frontend/__tests__/components/chat/chat-input.test.tsx index 2e65f27bf5..3055cec183 100644 --- a/frontend/__tests__/components/chat/chat-input.test.tsx +++ b/frontend/__tests__/components/chat/chat-input.test.tsx @@ -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(); + render(); 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", () => { diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index 6ec74ddc88..3a1d5594fe 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -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 diff --git a/frontend/__tests__/components/upload-image-input.test.tsx b/frontend/__tests__/components/upload-image-input.test.tsx index e95b80c969..c16d60d4c7 100644 --- a/frontend/__tests__/components/upload-image-input.test.tsx +++ b/frontend/__tests__/components/upload-image-input.test.tsx @@ -41,19 +41,6 @@ describe("UploadImageInput", () => { expect(onUploadMock).toHaveBeenNthCalledWith(1, files); }); - it("should not upload any file that is not an image", async () => { - render(); - - 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(); expect(screen.getByTestId("default-label")).toBeInTheDocument(); diff --git a/frontend/src/api/file-service/file-service.api.ts b/frontend/src/api/file-service/file-service.api.ts index 3899251b10..12f83ddcd0 100644 --- a/frontend/src/api/file-service/file-service.api.ts +++ b/frontend/src/api/file-service/file-service.api.ts @@ -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 { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + const url = `${getConversationUrl(conversationId)}/upload-files`; + const response = await openHands.post( + url, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + }, + ); + return response.data; + } } diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index b11ac91d48..e2533fbfcc 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -10,7 +10,6 @@ export interface SaveFileSuccessResponse { } export interface FileUploadSuccessResponse { - message: string; uploaded_files: string[]; skipped_files: { name: string; reason: string }[]; } diff --git a/frontend/src/components/features/chat/chat-input.tsx b/frontend/src/components/features/chat/chat-input.tsx index 9779150322..5e1528cc40 100644 --- a/frontend/src/components/features/chat/chat-input.tsx +++ b/frontend/src/components/features/chat/chat-input.tsx @@ -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["className"]; buttonClassName?: React.HTMLAttributes["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) => { // 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) => { 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); } } }; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 28b6e63983..9862df8506 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -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 && ( handleSendMessage(value, [])} + onSuggestionsClick={(value) => handleSendMessage(value, [], [])} /> )} diff --git a/frontend/src/components/features/chat/event-content-helpers/parse-message-from-event.ts b/frontend/src/components/features/chat/event-content-helpers/parse-message-from-event.ts new file mode 100644 index 0000000000..2a6f288b1c --- /dev/null +++ b/frontend/src/components/features/chat/event-content-helpers/parse-message-from-event.ts @@ -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]; +}; diff --git a/frontend/src/components/features/chat/event-message.tsx b/frontend/src/components/features/chat/event-message.tsx index 531f266b3d..fa8bd57ed0 100644 --- a/frontend/src/components/features/chat/event-message.tsx +++ b/frontend/src/components/features/chat/event-message.tsx @@ -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 ( - + {event.args.image_urls && event.args.image_urls.length > 0 && ( )} + {event.args.file_urls && event.args.file_urls.length > 0 && ( + + )} {shouldShowConfirmationButtons && } ); diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index 40b52498aa..d9428f4347 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -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([]); + const [files, setFiles] = React.useState([]); - 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, 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 && ( + f.name)} + onRemove={handleRemoveFile} + /> + )}
diff --git a/frontend/src/components/features/files/file-item.tsx b/frontend/src/components/features/files/file-item.tsx new file mode 100644 index 0000000000..6e0db9b359 --- /dev/null +++ b/frontend/src/components/features/files/file-item.tsx @@ -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 ( +
+ + {filename} + {onRemove && } +
+ ); +} diff --git a/frontend/src/components/features/files/file-list.tsx b/frontend/src/components/features/files/file-list.tsx new file mode 100644 index 0000000000..730eb0939b --- /dev/null +++ b/frontend/src/components/features/files/file-list.tsx @@ -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 ( +
+ {files.map((f, index) => ( + onRemove?.(index) : undefined} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/features/images/image-carousel.tsx b/frontend/src/components/features/images/image-carousel.tsx index 5059482f9f..d4d419a288 100644 --- a/frontend/src/components/features/images/image-carousel.tsx +++ b/frontend/src/components/features/images/image-carousel.tsx @@ -60,7 +60,7 @@ export function ImageCarousel({ key={index} size={size} src={src} - onRemove={onRemove && (() => onRemove(index))} + onRemove={onRemove ? () => onRemove?.(index) : undefined} /> ))}
diff --git a/frontend/src/components/features/images/image-preview.tsx b/frontend/src/components/features/images/image-preview.tsx index 37dc314a4b..f027df6ec7 100644 --- a/frontend/src/components/features/images/image-preview.tsx +++ b/frontend/src/components/features/images/image-preview.tsx @@ -15,7 +15,12 @@ export function ImagePreview({ return (
- {onRemove && } + {onRemove && ( + + )}
); } diff --git a/frontend/src/components/features/images/upload-image-input.tsx b/frontend/src/components/features/images/upload-image-input.tsx index c55d695b12..5cf825426b 100644 --- a/frontend/src/components/features/images/upload-image-input.tsx +++ b/frontend/src/components/features/images/upload-image-input.tsx @@ -8,10 +8,7 @@ interface UploadImageInputProps { export function UploadImageInput({ onUpload, label }: UploadImageInputProps) { const handleUpload = (event: React.ChangeEvent) => { 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) { void; + className?: React.HTMLAttributes["className"]; } -export function RemoveButton({ onClick }: RemoveButtonProps) { +export function RemoveButton({ onClick, className }: RemoveButtonProps) { return ( ); } diff --git a/frontend/src/components/shared/task-form.tsx b/frontend/src/components/shared/task-form.tsx deleted file mode 100644 index f6d35c8423..0000000000 --- a/frontend/src/components/shared/task-form.tsx +++ /dev/null @@ -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; -} - -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) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - - const q = formData.get("q")?.toString(); - createConversation({ q }); - }; - - return ( -
-
- -
- {isPending ? ( -
- -
- ) : ( - { - 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"} - /> - )} -
- - { - const promises = uploadedFiles.map(convertImageToBase64); - const base64Images = await Promise.all(promises); - base64Images.forEach((base64) => { - dispatch(addFile(base64)); - }); - }} - label={} - /> - {files.length > 0 && ( - dispatch(removeFile(index))} - /> - )} -
- ); -} diff --git a/frontend/src/hooks/mutation/use-upload-files.ts b/frontend/src/hooks/mutation/use-upload-files.ts new file mode 100644 index 0000000000..6118291473 --- /dev/null +++ b/frontend/src/hooks/mutation/use-upload-files.ts @@ -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, + }, + }); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 5842a01e7c..58d64aacb1 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 020ba61ad7..3dc2c32f3c 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "切断されました", diff --git a/frontend/src/mocks/mock-ws-helpers.ts b/frontend/src/mocks/mock-ws-helpers.ts index 4b2f9fcbe6..29b84a0a28 100644 --- a/frontend/src/mocks/mock-ws-helpers.ts +++ b/frontend/src/mocks/mock-ws-helpers.ts @@ -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: [], }, }); diff --git a/frontend/src/services/chat-service.ts b/frontend/src/services/chat-service.ts index 686d4d7e0d..1b14df70eb 100644 --- a/frontend/src/services/chat-service.ts +++ b/frontend/src/services/chat-service.ts @@ -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; } diff --git a/frontend/src/types/core/actions.ts b/frontend/src/types/core/actions.ts index abbadd708f..eb6e919e65 100644 --- a/frontend/src/types/core/actions.ts +++ b/frontend/src/types/core/actions.ts @@ -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; }; } diff --git a/frontend/src/utils/is-file-image.ts b/frontend/src/utils/is-file-image.ts new file mode 100644 index 0000000000..c72c485e97 --- /dev/null +++ b/frontend/src/utils/is-file-image.ts @@ -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/"); diff --git a/openhands/events/action/message.py b/openhands/events/action/message.py index 1e8eb7568a..767494e324 100644 --- a/openhands/events/action/message.py +++ b/openhands/events/action/message.py @@ -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 diff --git a/openhands/server/file_config.py b/openhands/server/file_config.py index ab1eec5104..799bc21fe8 100644 --- a/openhands/server/file_config.py +++ b/openhands/server/file_config.py @@ -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 diff --git a/openhands/server/files.py b/openhands/server/files.py new file mode 100644 index 0000000000..7d296ba107 --- /dev/null +++ b/openhands/server/files.py @@ -0,0 +1,12 @@ +from pydantic import ( + BaseModel, +) + + +class POSTUploadFilesModel(BaseModel): + """ + Upload files response model + """ + + file_urls: list[str] + skipped_files: list[str] diff --git a/openhands/server/routes/files.py b/openhands/server/routes/files.py index 1c283549b2..2384651f79 100644 --- a/openhands/server/routes/files.py +++ b/openhands/server/routes/files.py @@ -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, + }, + ) diff --git a/tests/unit/test_action_serialization.py b/tests/unit/test_action_serialization.py index 05459ae850..a7aaa263fa 100644 --- a/tests/unit/test_action_serialization.py +++ b/tests/unit/test_action_serialization.py @@ -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, }, } diff --git a/tests/unit/test_json.py b/tests/unit/test_json.py index 85ab265a53..6acfe60acc 100644 --- a/tests/unit/test_json.py +++ b/tests/unit/test_json.py @@ -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, }, }