diff --git a/frontend/src/components/Planner.tsx b/frontend/src/components/Planner.tsx index 37428d7ffb..588b68d0cf 100644 --- a/frontend/src/components/Planner.tsx +++ b/frontend/src/components/Planner.tsx @@ -1,7 +1,82 @@ import React from "react"; +import { + FaCheckCircle, + FaQuestionCircle, + FaRegCheckCircle, + FaRegCircle, + FaRegClock, + FaRegTimesCircle, +} from "react-icons/fa"; +import { useSelector } from "react-redux"; +import { Plan, Task, TaskState } from "../services/planService"; +import { RootState } from "../store"; + +function StatusIcon({ status }: { status: TaskState }): JSX.Element { + switch (status) { + case TaskState.OPEN_STATE: + return ; + case TaskState.COMPLETED_STATE: + return ; + case TaskState.ABANDONED_STATE: + return ; + case TaskState.IN_PROGRESS_STATE: + return ; + case TaskState.VERIFIED_STATE: + return ; + default: + return ; + } +} + +function TaskCard({ task, level }: { task: Task; level: number }): JSX.Element { + return ( +
+
+
+ +
+
{task.goal}
+
+ {task.subtasks.length > 0 && ( +
+ {task.subtasks.map((subtask) => ( + + ))} +
+ )} +
+ ); +} + +interface PlanProps { + plan: Plan; +} + +function PlanContainer({ plan }: PlanProps): JSX.Element { + if (plan.mainGoal === undefined) { + return ( +
+ Nothing is currently planned. Start a task for this to change. +
+ ); + } + return ( +
+ +
+ ); +} function Planner(): JSX.Element { - return
Coming soon...
; + const plan = useSelector((state: RootState) => state.plan.plan); + + return ( +
+ +
+ ); } export default Planner; diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts index cdbfb3d4d6..01932d7331 100644 --- a/frontend/src/services/actions.ts +++ b/frontend/src/services/actions.ts @@ -1,14 +1,16 @@ +import { changeTaskState } from "../state/agentSlice"; import { setScreenshotSrc, setUrl } from "../state/browserSlice"; import { appendAssistantMessage } from "../state/chatSlice"; import { setCode, updatePath } from "../state/codeSlice"; import { appendInput } from "../state/commandSlice"; +import { setPlan } from "../state/planSlice"; import { setInitialized } from "../state/taskSlice"; import store from "../store"; +import ActionType from "../types/ActionType"; import { ActionMessage } from "../types/Message"; import { SocketMessage } from "../types/ResponseType"; import { handleObservationMessage } from "./observations"; -import ActionType from "../types/ActionType"; -import { changeTaskState } from "../state/agentSlice"; +import { getPlan } from "./planService"; const messageActions = { [ActionType.INIT]: () => { @@ -33,6 +35,12 @@ const messageActions = { [ActionType.RUN]: (message: ActionMessage) => { store.dispatch(appendInput(message.args.command)); }, + [ActionType.ADD_TASK]: () => { + getPlan().then((fetchedPlan) => store.dispatch(setPlan(fetchedPlan))); + }, + [ActionType.MODIFY_TASK]: () => { + getPlan().then((fetchedPlan) => store.dispatch(setPlan(fetchedPlan))); + }, [ActionType.CHANGE_TASK_STATE]: (message: ActionMessage) => { store.dispatch(changeTaskState(message.args.task_state)); }, diff --git a/frontend/src/services/planService.ts b/frontend/src/services/planService.ts new file mode 100644 index 0000000000..7fd31a71fc --- /dev/null +++ b/frontend/src/services/planService.ts @@ -0,0 +1,33 @@ +export type Plan = { + mainGoal: string | undefined; + task: Task; +}; + +export type Task = { + id: string; + goal: string; + parent: "Task | None"; + subtasks: Task[]; + state: TaskState; +}; + +export enum TaskState { + OPEN_STATE = "open", + COMPLETED_STATE = "completed", + ABANDONED_STATE = "abandoned", + IN_PROGRESS_STATE = "in_progress", + VERIFIED_STATE = "verified", +} + +export async function getPlan(): Promise { + const headers = new Headers({ + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token")}`, + }); + const res = await fetch("/api/plan", { headers }); + if (res.status !== 200) { + return undefined; + } + const data = await res.json(); + return JSON.parse(data) as Plan; +} diff --git a/frontend/src/state/planSlice.ts b/frontend/src/state/planSlice.ts new file mode 100644 index 0000000000..2304fe9c41 --- /dev/null +++ b/frontend/src/state/planSlice.ts @@ -0,0 +1,27 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { Plan, TaskState } from "../services/planService"; + +export const planSlice = createSlice({ + name: "plan", + initialState: { + plan: { + mainGoal: undefined, + task: { + id: "", + goal: "", + parent: "Task | None", + subtasks: [], + state: TaskState.OPEN_STATE, + }, + } as Plan, + }, + reducers: { + setPlan: (state, action) => { + state.plan = action.payload as Plan; + }, + }, +}); + +export const { setPlan } = planSlice.actions; + +export default planSlice.reducer; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5fa4128088..5e717d9940 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,12 +1,13 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import agentReducer from "./state/agentSlice"; import browserReducer from "./state/browserSlice"; import chatReducer from "./state/chatSlice"; import codeReducer from "./state/codeSlice"; import commandReducer from "./state/commandSlice"; -import taskReducer from "./state/taskSlice"; import errorsReducer from "./state/errorsSlice"; +import planReducer from "./state/planSlice"; import settingsReducer from "./state/settingsSlice"; -import agentReducer from "./state/agentSlice"; +import taskReducer from "./state/taskSlice"; export const rootReducer = combineReducers({ browser: browserReducer, @@ -16,6 +17,7 @@ export const rootReducer = combineReducers({ task: taskReducer, errors: errorsReducer, settings: settingsReducer, + plan: planReducer, agent: agentReducer, }); diff --git a/frontend/src/types/ActionType.tsx b/frontend/src/types/ActionType.tsx index 773d591491..1d88324eb0 100644 --- a/frontend/src/types/ActionType.tsx +++ b/frontend/src/types/ActionType.tsx @@ -30,6 +30,12 @@ enum ActionType { // use the finish action to stop working. FINISH = "finish", + // Adds a task to the plan. + ADD_TASK = "add_task", + + // Updates a task in the plan. + MODIFY_TASK = "modify_task", + CHANGE_TASK_STATE = "change_task_state", } diff --git a/frontend/src/types/TabOption.tsx b/frontend/src/types/TabOption.tsx index 6848fd5bec..b114c0da28 100644 --- a/frontend/src/types/TabOption.tsx +++ b/frontend/src/types/TabOption.tsx @@ -6,6 +6,6 @@ enum TabOption { type TabType = TabOption.PLANNER | TabOption.CODE | TabOption.BROWSER; -const AllTabs = [TabOption.CODE, TabOption.BROWSER]; +const AllTabs = [TabOption.CODE, TabOption.BROWSER, TabOption.PLANNER]; export { AllTabs, TabOption, type TabType }; diff --git a/opendevin/controller/agent_controller.py b/opendevin/controller/agent_controller.py index 07ab807342..eb7e6d4ac9 100644 --- a/opendevin/controller/agent_controller.py +++ b/opendevin/controller/agent_controller.py @@ -1,27 +1,26 @@ import asyncio import time -from typing import List, Callable -from opendevin.plan import Plan -from opendevin.state import State -from opendevin.agent import Agent -from opendevin.observation import Observation, AgentErrorObservation, NullObservation +from typing import Callable, List + from litellm.exceptions import APIConnectionError from openai import AuthenticationError from opendevin import config -from opendevin.logger import opendevin_logger as logger - -from opendevin.exceptions import MaxCharsExceedError -from .action_manager import ActionManager - from opendevin.action import ( Action, - NullAction, AgentFinishAction, + NullAction, ) -from opendevin.exceptions import AgentNoActionError +from opendevin.agent import Agent +from opendevin.exceptions import AgentNoActionError, MaxCharsExceedError +from opendevin.logger import opendevin_logger as logger +from opendevin.observation import AgentErrorObservation, NullObservation, Observation +from opendevin.plan import Plan +from opendevin.state import State + from ..action.tasks import TaskStateChangedAction from ..schema import TaskState +from .action_manager import ActionManager MAX_ITERATIONS = config.get('MAX_ITERATIONS') MAX_CHARS = config.get('MAX_CHARS') @@ -219,3 +218,6 @@ class AgentController: await asyncio.sleep( 0.001 ) # Give back control for a tick, so we can await in callbacks + + def get_state(self): + return self.state diff --git a/opendevin/server/listen.py b/opendevin/server/listen.py index baad8b088c..aca82de573 100644 --- a/opendevin/server/listen.py +++ b/opendevin/server/listen.py @@ -1,12 +1,13 @@ +import json import uuid from pathlib import Path import litellm -from fastapi import Depends, FastAPI, WebSocket, HTTPException, Query, status +from fastapi import Depends, FastAPI, HTTPException, Query, Response, WebSocket, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse, JSONResponse import agenthub # noqa F401 (we import this to get the agents registered) from opendevin import config, files @@ -45,9 +46,9 @@ async def websocket_endpoint(websocket: WebSocket): @app.get('/api/litellm-models') async def get_litellm_models(): - """ + ''' Get all models supported by LiteLLM. - """ + ''' return list(set(litellm.model_list + list(litellm.model_cost.keys()))) @@ -72,7 +73,9 @@ async def get_token( sid = get_sid_from_token(credentials.credentials) if not sid: sid = str(uuid.uuid4()) - logger.info(f'Invalid or missing credentials, generating new session ID: {sid}') + logger.info( + f'Invalid or missing credentials, generating new session ID: {sid}' + ) else: sid = str(uuid.uuid4()) logger.info(f'No credentials provided, generating new session ID: {sid}') @@ -117,7 +120,9 @@ def refresh_files(): @app.get('/api/list-files') -def list_files(relpath: str = Query(None, description='Relative path from workspace base')): +def list_files( + relpath: str = Query(None, description='Relative path from workspace base') +): """Refreshes and returns the files and directories from a specified subdirectory or the base directory if no subdirectory is specified, limited to one level deep.""" base_path = Path(config.get('WORKSPACE_BASE')).resolve() full_path = (base_path / relpath).resolve() if relpath is not None else base_path @@ -127,7 +132,11 @@ def list_files(relpath: str = Query(None, description='Relative path from worksp # Ensure path exists, is a directory, # And is within the workspace base directory - to prevent directory traversal attacks # https://owasp.org/www-community/attacks/Path_Traversal - if not full_path.exists() or not full_path.is_dir() or not str(full_path).startswith(str(base_path)): + if ( + not full_path.exists() + or not full_path.is_dir() + or not str(full_path).startswith(str(base_path)) + ): raise HTTPException(status_code=400, detail='Invalid path provided.') structure = files.get_single_level_folder_structure(base_path, full_path) @@ -153,6 +162,28 @@ def select_file(file: str): return {'code': content} +@app.get('/api/plan') +def get_plan( + credentials: HTTPAuthorizationCredentials = Depends(security_scheme), +): + sid = get_sid_from_token(credentials.credentials) + agent = agent_manager.sid_to_agent[sid] + controller = agent.controller + if controller is not None: + state = controller.get_state() + if state is not None: + return JSONResponse( + status_code=status.HTTP_200_OK, + content=json.dumps( + { + 'mainGoal': state.plan.main_goal, + 'task': state.plan.task.to_dict(), + } + ), + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @app.get('/') async def docs_redirect(): response = RedirectResponse(url='/index.html')