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 (
-
-
-
{
- 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,
},
}