Просмотр исходного кода

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 год назад
Родитель
Сommit
545327cc1e

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

@@ -3,15 +3,17 @@ import {
   IoIosArrowBack,
   IoIosArrowBack,
   IoIosArrowForward,
   IoIosArrowForward,
   IoIosRefresh,
   IoIosRefresh,
+  IoIosCloudUpload,
 } from "react-icons/io";
 } from "react-icons/io";
 import { twMerge } from "tailwind-merge";
 import { twMerge } from "tailwind-merge";
-import { WorkspaceFile, getWorkspace } from "#/services/fileService";
+import { WorkspaceFile, getWorkspace, uploadFile } from "#/services/fileService";
 import IconButton from "../IconButton";
 import IconButton from "../IconButton";
 import ExplorerTree from "./ExplorerTree";
 import ExplorerTree from "./ExplorerTree";
 import { removeEmptyNodes } from "./utils";
 import { removeEmptyNodes } from "./utils";
 
 
 interface ExplorerActionsProps {
 interface ExplorerActionsProps {
   onRefresh: () => void;
   onRefresh: () => void;
+  onUpload: () => void;
   toggleHidden: () => void;
   toggleHidden: () => void;
   isHidden: boolean;
   isHidden: boolean;
 }
 }
@@ -19,6 +21,7 @@ interface ExplorerActionsProps {
 function ExplorerActions({
 function ExplorerActions({
   toggleHidden,
   toggleHidden,
   onRefresh,
   onRefresh,
+  onUpload,
   isHidden,
   isHidden,
 }: ExplorerActionsProps) {
 }: ExplorerActionsProps) {
   return (
   return (
@@ -29,17 +32,30 @@ function ExplorerActions({
       )}
       )}
     >
     >
       {!isHidden && (
       {!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
       <IconButton
@@ -56,8 +72,8 @@ function ExplorerActions({
             />
             />
           )
           )
         }
         }
-        testId="close"
-        ariaLabel="Close workspace"
+        testId="toggle"
+        ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
         onClick={toggleHidden}
         onClick={toggleHidden}
       />
       />
     </div>
     </div>
@@ -71,12 +87,33 @@ interface FileExplorerProps {
 function FileExplorer({ onFileClick }: FileExplorerProps) {
 function FileExplorer({ onFileClick }: FileExplorerProps) {
   const [workspace, setWorkspace] = React.useState<WorkspaceFile>();
   const [workspace, setWorkspace] = React.useState<WorkspaceFile>();
   const [isHidden, setIsHidden] = React.useState(false);
   const [isHidden, setIsHidden] = React.useState(false);
+  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
 
 
   const getWorkspaceData = async () => {
   const getWorkspaceData = async () => {
     const wsFile = await getWorkspace();
     const wsFile = await getWorkspace();
     setWorkspace(removeEmptyNodes(wsFile));
     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(() => {
   React.useEffect(() => {
     (async () => {
     (async () => {
       await getWorkspaceData();
       await getWorkspaceData();
@@ -105,8 +142,15 @@ function FileExplorer({ onFileClick }: FileExplorerProps) {
           isHidden={isHidden}
           isHidden={isHidden}
           toggleHidden={() => setIsHidden((prev) => !prev)}
           toggleHidden={() => setIsHidden((prev) => !prev)}
           onRefresh={getWorkspaceData}
           onRefresh={getWorkspaceData}
+          onUpload={selectFileInput}
         />
         />
       </div>
       </div>
+      <input
+        type="file"
+        ref={fileInputRef}
+        style={{ display: "none" }}
+        onChange={uploadFileData}
+      />
     </div>
     </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;
   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> {
 export async function getWorkspace(): Promise<WorkspaceFile> {
   const res = await fetch("/api/refresh-files");
   const res = await fetch("/api/refresh-files");
   const data = await res.json();
   const data = await res.json();

+ 20 - 1
opendevin/server/listen.py

@@ -1,9 +1,10 @@
 import json
 import json
+import shutil
 import uuid
 import uuid
 from pathlib import Path
 from pathlib import Path
 
 
 import litellm
 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.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -137,6 +138,24 @@ def select_file(file: str):
     return {'code': content}
     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')
 @app.get('/api/plan')
 def get_plan(
 def get_plan(
     credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
     credentials: HTTPAuthorizationCredentials = Depends(security_scheme),

+ 15 - 1
poetry.lock

@@ -4446,6 +4446,20 @@ files = [
 [package.extras]
 [package.extras]
 cli = ["click (>=5.0)"]
 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]]
 [[package]]
 name = "pytz"
 name = "pytz"
 version = "2024.1"
 version = "2024.1"
@@ -6329,4 +6343,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.11"
 python-versions = "^3.11"
-content-hash = "62bc3f49304639795bc824fb0ceacbfaeb40c88ea9e5a90d13d4a3bf5219dbba"
+content-hash = "4e50c6a8427ab71919c0c5bcdfbb83afe71aa50966452b23bca62e0b69da4c30"

+ 1 - 0
pyproject.toml

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