Wire up frontend (#128)

This commit is contained in:
Jim Su 2024-03-24 22:07:38 -04:00 committed by GitHub
parent 0f4a11685e
commit 335a91610e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 323 additions and 148 deletions

View File

@ -10,7 +10,7 @@ PYTHONPATH=`pwd`:$PYTHONPATH python3 opendevin/main.py -d ./workspace -c CodeAct
```
Example: prompts `gpt-3.5-turbo-0125` to write a flask server, install `flask` library, and start the server.
Example: prompts `gpt-4-0125-preview` to write a flask server, install `flask` library, and start the server.
<img width="951" alt="image" src="https://github.com/OpenDevin/OpenDevin/assets/38853559/325c3115-a343-4cc5-a92b-f1e5d552a077">

View File

@ -1 +1 @@
VITE_TERMINAL_WS_URL="ws://localhost:8080/ws"
VITE_TERMINAL_WS_URL="ws://localhost:3000/ws"

View File

@ -25,6 +25,7 @@
"state"
]
}],
"import/prefer-default-export": "off",
"no-underscore-dangle": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/click-events-have-key-events": "off",

View File

@ -6,6 +6,7 @@ import Terminal from "./components/Terminal";
import Planner from "./components/Planner";
import CodeEditor from "./components/CodeEditor";
import Browser from "./components/Browser";
import Errors from "./components/Errors";
const TAB_OPTIONS = ["terminal", "planner", "code", "browser"] as const;
type TabOption = (typeof TAB_OPTIONS)[number];
@ -26,19 +27,19 @@ function Tab({ name, active, onClick }: TabProps): JSX.Element {
const tabData = {
terminal: {
name: "Terminal",
component: <Terminal />,
component: null,
},
planner: {
name: "Planner",
component: <Planner />,
component: <Planner key="planner" />,
},
code: {
name: "Code Editor",
component: <CodeEditor />,
component: <CodeEditor key="code" />,
},
browser: {
name: "Browser",
component: <Browser />,
component: <Browser key="browser" />,
},
};
@ -47,6 +48,7 @@ function App(): JSX.Element {
return (
<div className="app">
<Errors />
<div className="left-pane">
<ChatInterface />
</div>
@ -61,6 +63,8 @@ function App(): JSX.Element {
/>
))}
</div>
{/* Keep terminal permanently open - see component for more details */}
<Terminal key="terminal" hidden={activeTab !== "terminal"} />
<div className="tab-content">{tabData[activeTab].component}</div>
</div>
</div>

View File

@ -1,80 +1,91 @@
.chat-interface {
display: flex;
flex-direction: column;
height: 100%;
padding: 0px;
background-color: #252526;
}
.message-list {
flex: 1;
overflow-y: auto;
margin-bottom: 20px;
}
.message-layout {
display: flex;
margin-bottom: 10px;
}
.message{
display: flex;
}
.user-message{
display: flex;
flex-direction: row-reverse;
margin:10px 10px 0 auto
}
.user-message .message-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0px;
background-color: #252526;
}
.initializing-status {
display: flex;
align-items: center;
margin: auto;
height: 100%;
}
.message-list {
flex: 1;
overflow-y: auto;
padding-top: 1rem;
}
.message-layout {
display: flex;
margin-bottom: 10px;
}
.message{
display: flex;
}
.user-message{
display: flex;
flex-direction: row-reverse;
margin:10px 10px 0 auto
}
.user-message .message-content {
background-color: #007acc ;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin:0 10px;
}
.message-content {
background-color: #333333;
color: #fff;
padding: 10px;
border-radius: 5px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.input-container {
display: flex;
align-items: center;
margin-top: auto;
}
.input-container input {
flex: 1;
padding: 10px;
border: none;
border-radius: 5px;
margin: 0 10px;
background-color: #3c3c3c;
color: #fff;
}
.input-container button {
padding: 10px 20px;
background-color: #007acc;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.button-text {
font-size: 16px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin:0 10px;
}
.message-content {
background-color: #333333;
color: #fff;
padding: 10px;
border-radius: 5px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.input-container svg {
height: 16px;
}
.input-container {
display: flex;
align-items: center;
margin-top: auto;
margin: 0.5rem;
}
.input-container input {
flex: 1;
padding: 10px;
border: none;
border-radius: 5px;
margin: 0 10px;
background-color: #3c3c3c;
color: #fff;
font-size: 16px;
}
.input-container button {
padding: 10px 20px;
background-color: #007acc;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.button-text {
font-size: 16px;
}
.input-container svg {
height: 16px;
}

View File

@ -1,41 +1,57 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import "./ChatInterface.css";
import userAvatar from "../assets/user-avatar.png";
import assistantAvatar from "../assets/assistant-avatar.png";
import { sendMessage } from "../state/chatSlice";
import { RootState } from "../store";
import { sendChatMessage } from "../services/chatService";
function MessageList(): JSX.Element {
const { messages } = useSelector((state: RootState) => state.chat);
return (
<div className="message-list">
{messages.map((msg, index) => (
<div key={index} className="message-layout">
<div
className={`${msg.sender === "user" ? "user-message" : "message"}`}
>
<img
src={msg.sender === "user" ? userAvatar : assistantAvatar}
alt={`${msg.sender} avatar`}
className="avatar"
/>
<div className="message-content">{msg.content}</div>
</div>
</div>
))}
</div>
);
}
function InitializingStatus(): JSX.Element {
return (
<div className="initializing-status">
<img src={assistantAvatar} alt="assistant avatar" className="avatar" />
<div>Initializing agent (may take up to 10 seconds)...</div>
</div>
);
}
function ChatInterface(): JSX.Element {
const messages = useSelector((state: RootState) => state.chat.messages);
const { initialized } = useSelector((state: RootState) => state.task);
const [inputMessage, setInputMessage] = useState("");
const dispatch = useDispatch();
const handleSendMessage = () => {
if (inputMessage.trim() !== "") {
dispatch(sendMessage(inputMessage));
sendChatMessage(inputMessage);
setInputMessage("");
}
};
return (
<div className="chat-interface">
<div className="message-list">
{messages.map((msg, index) => (
<div key={index} className="message-layout">
<div
className={`${msg.sender === "user" ? "user-message" : "message"}`}
>
<img
src={msg.sender === "user" ? userAvatar : assistantAvatar}
alt={`${msg.sender} avatar`}
className="avatar"
/>
<div className="message-content">{msg.content}</div>
</div>
</div>
))}
</div>
{initialized ? <MessageList /> : <InitializingStatus />}
<div className="input-container">
<button className="attach-button" type="button" aria-label="file">
<svg
@ -63,6 +79,7 @@ function ChatInterface(): JSX.Element {
handleSendMessage();
}
}}
disabled={!initialized}
/>
<button type="button" onClick={handleSendMessage}>
<span className="button-text">Send</span>

View File

@ -1,18 +1,18 @@
import React from "react";
import Editor from "@monaco-editor/react";
import { useSelector } from "react-redux";
import { RootState } from "../store";
function CodeEditor(): JSX.Element {
const handleEditorChange = (value: string | undefined) => {
console.log("Content changed:", value);
};
const code = useSelector((state: RootState) => state.code.code);
return (
<Editor
height="100%"
theme="vs-dark"
defaultLanguage="javascript"
defaultValue="// Welcome to OpenDevin!"
onChange={handleEditorChange}
defaultLanguage="python"
defaultValue="# Welcome to OpenDevin!"
value={code}
/>
);
}

View File

@ -0,0 +1,15 @@
.errors {
position: fixed;
left: 50%;
transform: translateX(-50%);
top: 1rem;
z-index: 1000;
}
.error {
background-color: #B00020;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.5);
margin-bottom: 0.5rem;
}

View File

@ -0,0 +1,20 @@
import React from "react";
import { useSelector } from "react-redux";
import { RootState } from "../store";
import "./Errors.css";
function Errors(): JSX.Element {
const errors = useSelector((state: RootState) => state.errors.errors);
return (
<div className="errors">
{errors.map((error, index) => (
<div key={index} className="error">
ERROR: {error}
</div>
))}
</div>
);
}
export default Errors;

View File

@ -22,9 +22,15 @@ class JsonWebsocketAddon {
}),
);
this._socket.addEventListener("message", (event) => {
const { message } = JSON.parse(event.data);
if (message.action === "terminal") {
terminal.write(message.data);
const { action, args } = JSON.parse(event.data);
if (action === "run") {
terminal.writeln(args.command);
}
if (action === "output") {
args.output.split("\n").forEach((line: string) => {
terminal.writeln(line);
});
terminal.write("\n$ ");
}
});
}
@ -35,7 +41,16 @@ class JsonWebsocketAddon {
}
}
function Terminal(): JSX.Element {
type TerminalProps = {
hidden: boolean;
};
/**
* The terminal's content is set by write messages. To avoid complicated state logic,
* we keep the terminal persistently open as a child of <App /> and hidden when not in use.
*/
function Terminal({ hidden }: TerminalProps): JSX.Element {
const terminalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -49,6 +64,7 @@ function Terminal(): JSX.Element {
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
});
terminal.write("$ ");
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
@ -69,7 +85,16 @@ function Terminal(): JSX.Element {
};
}, []);
return <div ref={terminalRef} style={{ width: "100%", height: "100%" }} />;
return (
<div
ref={terminalRef}
style={{
width: "100%",
height: "100%",
display: hidden ? "none" : "block",
}}
/>
);
}
export default Terminal;

View File

@ -0,0 +1,10 @@
import { appendUserMessage } from "../state/chatSlice";
import socket from "../state/socket";
import store from "../store";
export function sendChatMessage(message: string): void {
store.dispatch(appendUserMessage(message));
const event = { action: "start", args: { task: message } };
const eventString = JSON.stringify(event);
socket.send(eventString);
}

View File

@ -5,29 +5,7 @@ type Message = {
sender: "user" | "assistant";
};
const initialMessages: Message[] = [
{
content:
"I want you to setup this project: https://github.com/mckaywrigley/assistant-ui",
sender: "user",
},
{
content:
"Got it, I'll get started on setting up the assistant UI project from the GitHub link you provided. I'll update you on my progress.",
sender: "assistant",
},
{ content: "Cloned repo from GitHub.", sender: "assistant" },
{ content: "You're doing great! Keep it up :)", sender: "user" },
{
content:
"Thanks! I've cloned the repo and am currently going through the README to make sure we get everything set up right. There's a detailed guide for local setup as well as instructions for hosting it. I'll follow the steps and keep you posted on the progress! If there are any specific configurations or features you want to prioritize, just let me know.",
sender: "assistant",
},
{
content: "Installed project dependencies using npm.",
sender: "assistant",
},
];
const initialMessages: Message[] = [];
export const chatSlice = createSlice({
name: "chat",
@ -35,12 +13,15 @@ export const chatSlice = createSlice({
messages: initialMessages,
},
reducers: {
sendMessage: (state, action) => {
appendUserMessage: (state, action) => {
state.messages.push({ content: action.payload, sender: "user" });
},
appendAssistantMessage: (state, action) => {
state.messages.push({ content: action.payload, sender: "assistant" });
},
},
});
export const { sendMessage } = chatSlice.actions;
export const { appendUserMessage, appendAssistantMessage } = chatSlice.actions;
export default chatSlice.reducer;

View File

@ -0,0 +1,17 @@
import { createSlice } from "@reduxjs/toolkit";
export const codeSlice = createSlice({
name: "code",
initialState: {
code: "# Welcome to OpenDevin!",
},
reducers: {
setCode: (state, action) => {
state.code = action.payload;
},
},
});
export const { setCode } = codeSlice.actions;
export default codeSlice.reducer;

View File

@ -0,0 +1,19 @@
import { createSlice } from "@reduxjs/toolkit";
const initialErrors: string[] = [];
export const errorsSlice = createSlice({
name: "errors",
initialState: {
errors: initialErrors,
},
reducers: {
appendError: (state, action) => {
state.errors.push(action.payload);
},
},
});
export const { appendError } = errorsSlice.actions;
export default errorsSlice.reducer;

View File

@ -1,23 +1,42 @@
import store from "../store";
import { setScreenshotSrc, setUrl } from "./browserSlice";
import { appendAssistantMessage } from "./chatSlice";
import { setCode } from "./codeSlice";
import { appendError } from "./errorsSlice";
import { setInitialized } from "./taskSlice";
const MESSAGE_ACTIONS = ["terminal", "planner", "code", "browser"] as const;
type MessageAction = (typeof MESSAGE_ACTIONS)[number];
type SocketMessage = {
action: MessageAction;
data: Record<string, unknown>;
message: string;
args: Record<string, unknown>;
};
const messageActions = {
browser: (message: SocketMessage) => {
const { url, screenshotSrc } = message.data;
initialize: () => {
store.dispatch(setInitialized(true));
store.dispatch(
appendAssistantMessage(
"Hello, I am OpenDevin, an AI Software Engineer. What would you like me to build you today?",
),
);
},
browse: (message: SocketMessage) => {
const { url, screenshotSrc } = message.args;
store.dispatch(setUrl(url));
store.dispatch(setScreenshotSrc(screenshotSrc));
},
terminal: () => {},
planner: () => {},
code: () => {},
write: (message: SocketMessage) => {
store.dispatch(setCode(message.args.contents));
},
think: (message: SocketMessage) => {
store.dispatch(appendAssistantMessage(message.args.thought));
},
finish: (message: SocketMessage) => {
store.dispatch(appendAssistantMessage(message.message));
},
};
const WS_URL = import.meta.env.VITE_TERMINAL_WS_URL;
@ -30,13 +49,22 @@ if (!WS_URL) {
const socket = new WebSocket(WS_URL);
socket.addEventListener("message", (event) => {
const { message } = JSON.parse(event.data);
console.log("Received message:", message);
const socketMessage = JSON.parse(event.data) as SocketMessage;
if (message.action in messageActions) {
const action = messageActions[message.action as MessageAction];
action(message);
if (socketMessage.action in messageActions) {
const actionFn =
messageActions[socketMessage.action as keyof typeof messageActions];
actionFn(socketMessage);
} else if (!socketMessage.action) {
store.dispatch(appendAssistantMessage(socketMessage.message));
}
});
socket.addEventListener("error", () => {
store.dispatch(
appendError(
`Failed connection to server. Please ensure the server is reachable at ${WS_URL}.`,
),
);
});
export default socket;

View File

@ -0,0 +1,21 @@
import { createSlice } from "@reduxjs/toolkit";
export const taskSlice = createSlice({
name: "task",
initialState: {
initialized: false,
completed: false,
},
reducers: {
setInitialized: (state, action) => {
state.initialized = action.payload;
},
setCompleted: (state, action) => {
state.completed = action.payload;
},
},
});
export const { setInitialized, setCompleted } = taskSlice.actions;
export default taskSlice.reducer;

View File

@ -1,11 +1,17 @@
import { configureStore } from "@reduxjs/toolkit";
import browserReducer from "./state/browserSlice";
import chatReducer from "./state/chatSlice";
import codeReducer from "./state/codeSlice";
import taskReducer from "./state/taskSlice";
import errorsReducer from "./state/errorsSlice";
const store = configureStore({
reducer: {
browser: browserReducer,
chat: chatReducer,
code: codeReducer,
task: taskReducer,
errors: errorsReducer,
},
});

View File

@ -88,7 +88,7 @@ class Session:
model_name=model,
)
self.controller = AgentController(self.agent, directory, callbacks=[self.on_agent_event])
await self.send_message("Control loop started")
await self.send({"action": "initialize", "message": "Control loop started."})
async def start_task(self, start_event):
if "task" not in start_event.args: