Browse Source

upload file to workspace with button (#1394)

* upload file to workspace with button

* resolve filepath

* regenerate lock file

* fix lock file

* fix torch version

* fix lock

* fix poerty.lock

* fix lint

---------

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Tom Mery 1 year ago
parent
commit
545327cc1e

+ 58 - 14
frontend/src/components/file-explorer/FileExplorer.tsx

@@ -3,15 +3,17 @@ import {
   IoIosArrowBack,
   IoIosArrowForward,
   IoIosRefresh,
+  IoIosCloudUpload,
 } from "react-icons/io";
 import { twMerge } from "tailwind-merge";
-import { WorkspaceFile, getWorkspace } from "#/services/fileService";
+import { WorkspaceFile, getWorkspace, uploadFile } from "#/services/fileService";
 import IconButton from "../IconButton";
 import ExplorerTree from "./ExplorerTree";
 import { removeEmptyNodes } from "./utils";
 
 interface ExplorerActionsProps {
   onRefresh: () => void;
+  onUpload: () => void;
   toggleHidden: () => void;
   isHidden: boolean;
 }
@@ -19,6 +21,7 @@ interface ExplorerActionsProps {
 function ExplorerActions({
   toggleHidden,
   onRefresh,
+  onUpload,
   isHidden,
 }: ExplorerActionsProps) {
   return (
@@ -29,17 +32,30 @@ function ExplorerActions({
       )}
     >
       {!isHidden && (
-        <IconButton
-          icon={
-            <IoIosRefresh
-              size={16}
-              className="text-neutral-400 hover:text-neutral-100 transition"
-            />
-          }
-          testId="refresh"
-          ariaLabel="Refresh workspace"
-          onClick={onRefresh}
-        />
+        <>
+          <IconButton
+            icon={
+              <IoIosRefresh
+                size={16}
+                className="text-neutral-400 hover:text-neutral-100 transition"
+              />
+            }
+            testId="refresh"
+            ariaLabel="Refresh workspace"
+            onClick={onRefresh}
+          />
+          <IconButton
+            icon={
+              <IoIosCloudUpload
+                size={16}
+                className="text-neutral-400 hover:text-neutral-100 transition"
+              />
+            }
+            testId="upload"
+            ariaLabel="Upload File"
+            onClick={onUpload}
+          />
+        </>
       )}
 
       <IconButton
@@ -56,8 +72,8 @@ function ExplorerActions({
             />
           )
         }
-        testId="close"
-        ariaLabel="Close workspace"
+        testId="toggle"
+        ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
         onClick={toggleHidden}
       />
     </div>
@@ -71,12 +87,33 @@ interface FileExplorerProps {
 function FileExplorer({ onFileClick }: FileExplorerProps) {
   const [workspace, setWorkspace] = React.useState<WorkspaceFile>();
   const [isHidden, setIsHidden] = React.useState(false);
+  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
 
   const getWorkspaceData = async () => {
     const wsFile = await getWorkspace();
     setWorkspace(removeEmptyNodes(wsFile));
   };
 
+  const selectFileInput = () => {
+    fileInputRef.current?.click(); // Trigger the file browser
+  };
+
+  const uploadFileData = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files ? event.target.files[0] : null;
+    if (!file) {
+      console.log("No file selected.");
+      return;
+    }
+    console.log("File selected:", file);
+    try {
+      const response = await uploadFile(file);
+      console.log(response);
+      await getWorkspaceData(); // Refresh the workspace to show the new file
+    } catch (error) {
+      console.error("Error uploading file:", error);
+    }
+  };
+
   React.useEffect(() => {
     (async () => {
       await getWorkspaceData();
@@ -105,8 +142,15 @@ function FileExplorer({ onFileClick }: FileExplorerProps) {
           isHidden={isHidden}
           toggleHidden={() => setIsHidden((prev) => !prev)}
           onRefresh={getWorkspaceData}
+          onUpload={selectFileInput}
         />
       </div>
+      <input
+        type="file"
+        ref={fileInputRef}
+        style={{ display: "none" }}
+        onChange={uploadFileData}
+      />
     </div>
   );
 }

+ 18 - 0
frontend/src/services/fileService.ts

@@ -12,6 +12,24 @@ export async function selectFile(file: string): Promise<string> {
   return data.code as string;
 }
 
+export async function uploadFile(file: File): Promise<string> {
+  const formData = new FormData();
+  formData.append("file", file);
+
+  const res = await fetch("/api/upload-file", {
+    method: "POST",
+    body: formData,
+  });
+
+  const data = await res.json();
+
+  if (res.status !== 200) {
+    throw new Error(data.error || "Failed to upload file.");
+  }
+
+  return `File uploaded: ${data.filename}, Location: ${data.location}`;
+}
+
 export async function getWorkspace(): Promise<WorkspaceFile> {
   const res = await fetch("/api/refresh-files");
   const data = await res.json();

+ 20 - 1
opendevin/server/listen.py

@@ -1,9 +1,10 @@
 import json
+import shutil
 import uuid
 from pathlib import Path
 
 import litellm
-from fastapi import Depends, FastAPI, Response, WebSocket, status
+from fastapi import Depends, FastAPI, Response, UploadFile, WebSocket, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -137,6 +138,24 @@ def select_file(file: str):
     return {'code': content}
 
 
+@app.post('/api/upload-file')
+async def upload_file(file: UploadFile):
+    try:
+        workspace_base = config.get(ConfigType.WORKSPACE_BASE)
+        file_path = Path(workspace_base, file.filename)
+        # The following will check if the file is within the workspace base and throw an exception if not
+        file_path.resolve().relative_to(Path(workspace_base).resolve())
+        with open(file_path, 'wb') as buffer:
+            shutil.copyfileobj(file.file, buffer)
+    except Exception as e:
+        logger.error(f'Error saving file {file.filename}: {e}', exc_info=True)
+        return JSONResponse(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            content={'error': f'Error saving file: {e}'}
+        )
+    return {'filename': file.filename, 'location': str(file_path)}
+
+
 @app.get('/api/plan')
 def get_plan(
     credentials: HTTPAuthorizationCredentials = Depends(security_scheme),

+ 15 - 1
poetry.lock

@@ -4446,6 +4446,20 @@ files = [
 [package.extras]
 cli = ["click (>=5.0)"]
 
+[[package]]
+name = "python-multipart"
+version = "0.0.9"
+description = "A streaming multipart parser for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
+    {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
+]
+
+[package.extras]
+dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"]
+
 [[package]]
 name = "pytz"
 version = "2024.1"
@@ -6329,4 +6343,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "62bc3f49304639795bc824fb0ceacbfaeb40c88ea9e5a90d13d4a3bf5219dbba"
+content-hash = "4e50c6a8427ab71919c0c5bcdfbb83afe71aa50966452b23bca62e0b69da4c30"

+ 1 - 0
pyproject.toml

@@ -26,6 +26,7 @@ playwright = "*"
 e2b = "^0.14.13"
 pexpect = "*"
 jinja2 = "^3.1.3"
+python-multipart = "*"
 
 [tool.poetry.group.llama-index.dependencies]
 llama-index = "*"