mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(chat): support file upload (#8945)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
parent
a92d6904fc
commit
a9f26a13a6
@ -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", () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ export interface SaveFileSuccessResponse {
|
||||
}
|
||||
|
||||
export interface FileUploadSuccessResponse {
|
||||
message: string;
|
||||
uploaded_files: string[];
|
||||
skipped_files: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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];
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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]"
|
||||
/>
|
||||
|
||||
20
frontend/src/components/features/files/file-item.tsx
Normal file
20
frontend/src/components/features/files/file-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/features/files/file-list.tsx
Normal file
25
frontend/src/components/features/files/file-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -60,7 +60,7 @@ export function ImageCarousel({
|
||||
key={index}
|
||||
size={size}
|
||||
src={src}
|
||||
onRemove={onRemove && (() => onRemove(index))}
|
||||
onRemove={onRemove ? () => onRemove?.(index) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
13
frontend/src/hooks/mutation/use-upload-files.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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": "切断されました",
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
7
frontend/src/utils/is-file-image.ts
Normal file
7
frontend/src/utils/is-file-image.ts
Normal 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/");
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
12
openhands/server/files.py
Normal file
@ -0,0 +1,12 @@
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
)
|
||||
|
||||
|
||||
class POSTUploadFilesModel(BaseModel):
|
||||
"""
|
||||
Upload files response model
|
||||
"""
|
||||
|
||||
file_urls: list[str]
|
||||
skipped_files: list[str]
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user