diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 339268e92d1..bcd035f8e2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { useDisclosure } from "@nextui-org/react"; import React, { useEffect, useState } from "react"; import { Toaster } from "react-hot-toast"; import CogTooth from "#/assets/cog-tooth"; -import ChatInterface from "#/components/ChatInterface"; +import ChatInterface from "#/components/chat/ChatInterface"; import Errors from "#/components/Errors"; import { Container, Orientation } from "#/components/Resizable"; import Workspace from "#/components/Workspace"; diff --git a/frontend/src/components/AgentControlBar.tsx b/frontend/src/components/AgentControlBar.tsx index d73850803dd..331e14c4d9d 100644 --- a/frontend/src/components/AgentControlBar.tsx +++ b/frontend/src/components/AgentControlBar.tsx @@ -6,10 +6,10 @@ import PauseIcon from "#/assets/pause"; import PlayIcon from "#/assets/play"; import { changeTaskState } from "#/services/agentStateService"; import { clearMsgs } from "#/services/session"; -import { clearMessages } from "#/state/chatSlice"; import store, { RootState } from "#/store"; import AgentTaskAction from "#/types/AgentTaskAction"; import AgentTaskState from "#/types/AgentTaskState"; +import { clearMessages } from "#/state/chatSlice"; const TaskStateActionMap = { [AgentTaskAction.START]: AgentTaskState.RUNNING, diff --git a/frontend/src/components/ChatInterface.tsx b/frontend/src/components/ChatInterface.tsx deleted file mode 100644 index ee08c01d2fb..00000000000 --- a/frontend/src/components/ChatInterface.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { IoMdChatbubbles } from "react-icons/io"; -import Markdown from "react-markdown"; -import { useSelector } from "react-redux"; -import { useTypingEffect } from "#/hooks/useTypingEffect"; -import AgentTaskState from "../types/AgentTaskState"; -import { - addAssistantMessageToChat, - sendChatMessage, - setTypingActive, - takeOneAndType, -} from "#/services/chatService"; -import { Message } from "#/state/chatSlice"; -import { RootState } from "#/store"; -import ChatInput from "./ChatInput"; -import { code } from "./markdown/code"; - -interface IChatBubbleProps { - msg: Message; -} - -/** - * @returns jsx - * - * component used for typing effect when assistant replies - * - * makes uses of useTypingEffect hook - * - */ -function TypingChat() { - const { typeThis } = useSelector((state: RootState) => state.chat); - - const messageContent = useTypingEffect([typeThis?.content], { - loop: false, - setTypingActive, - playbackRate: 0.099, - addAssistantMessageToChat, - takeOneAndType, - typeThis, - }); - - return ( -
-
-
-
{messageContent}
-
-
-
- ); -} - -function ChatBubble({ msg }: IChatBubbleProps): JSX.Element { - return ( -
-
-
-
- {msg?.content} -
-
-
-
- ); -} - -function MessageList(): JSX.Element { - const messagesEndRef = useRef(null); - const { typingActive, newChatSequence, typeThis } = useSelector( - (state: RootState) => state.chat, - ); - - const messageScroll = () => { - messagesEndRef.current?.scrollIntoView({ - behavior: "auto", - block: "end", - }); - }; - - useEffect(() => { - messageScroll(); - if (!typingActive) return; - - const interval = setInterval(() => { - messageScroll(); - }, 100); - - // eslint-disable-next-line consistent-return - return () => clearInterval(interval); - }, [newChatSequence, typingActive]); - - useEffect(() => { - if (typeThis.content === "") return; - - if (!typingActive) setTypingActive(true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [typeThis]); - - return ( -
-
- {newChatSequence.map((msg, index) => ( - - ))} - {typingActive && } -
-
-
-
- ); -} - -function ChatInterface(): JSX.Element { - const { initialized } = useSelector((state: RootState) => state.task); - const { curTaskState } = useSelector((state: RootState) => state.agent); - - const onUserMessage = (msg: string) => { - const isNewTask = curTaskState === AgentTaskState.INIT; - sendChatMessage(msg, isNewTask); - }; - - return ( -
-
- - Chat -
- - -
- ); -} - -export default ChatInterface; diff --git a/frontend/src/components/chat/Chat.test.tsx b/frontend/src/components/chat/Chat.test.tsx new file mode 100644 index 00000000000..dcf4bf412eb --- /dev/null +++ b/frontend/src/components/chat/Chat.test.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import Chat from "./Chat"; + +const MESSAGES: Message[] = [ + { sender: "assistant", content: "Hello!" }, + { sender: "user", content: "Hi!" }, + { sender: "assistant", content: "How can I help you today?" }, +]; + +HTMLElement.prototype.scrollIntoView = vi.fn(); + +describe("Chat", () => { + it("should render chat messages", () => { + render(); + + const messages = screen.getAllByTestId("message"); + + expect(messages).toHaveLength(MESSAGES.length); + }); + + it("should scroll to the newest message", () => { + const { rerender } = render(); + + const newMessages: Message[] = [ + ...MESSAGES, + { sender: "user", content: "Create a spaceship" }, + ]; + + act(() => { + rerender(); + }); + + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx new file mode 100644 index 00000000000..426fa95a503 --- /dev/null +++ b/frontend/src/components/chat/Chat.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import ChatMessage from "./ChatMessage"; + +interface ChatProps { + messages: Message[]; +} + +function Chat({ messages }: ChatProps) { + const endOfMessagesRef = React.useRef(null); + + React.useEffect(() => { + endOfMessagesRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+ {messages.map((message, index) => ( + + ))} +
+
+ ); +} + +export default Chat; diff --git a/frontend/src/components/ChatInput.test.tsx b/frontend/src/components/chat/ChatInput.test.tsx similarity index 100% rename from frontend/src/components/ChatInput.test.tsx rename to frontend/src/components/chat/ChatInput.test.tsx diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx similarity index 98% rename from frontend/src/components/ChatInput.tsx rename to frontend/src/components/chat/ChatInput.tsx index b0e8ee6bfe5..568e43ef5ee 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -42,7 +42,7 @@ function ChatInput({ disabled, onSendMessage }: ChatInputProps) { onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} placeholder={t(I18nKey.CHAT_INTERFACE$INPUT_PLACEHOLDER)} - className="pt-2 pb-3 px-3" + className="pb-3 px-3" classNames={{ inputWrapper: "bg-neutral-700 border border-neutral-600 rounded-lg", input: "pr-16 text-neutral-400", diff --git a/frontend/src/components/chat/ChatInterface.test.tsx b/frontend/src/components/chat/ChatInterface.test.tsx new file mode 100644 index 00000000000..37290af5e37 --- /dev/null +++ b/frontend/src/components/chat/ChatInterface.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { act } from "react-dom/test-utils"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import ChatInterface from "./ChatInterface"; +import Socket from "#/services/socket"; +import ActionType from "#/types/ActionType"; +import { addAssistantMessage } from "#/state/chatSlice"; +import AgentTaskState from "#/types/AgentTaskState"; + +// avoid typing side-effect +vi.mock("#/hooks/useTyping", () => ({ + useTyping: vi.fn((text: string) => text), +})); + +const socketSpy = vi.spyOn(Socket, "send"); + +// This is for the scrollview ref in Chat.tsx +// TODO: Move this into test setup +HTMLElement.prototype.scrollIntoView = vi.fn(); + +const renderChatInterface = () => + renderWithProviders(, { + preloadedState: { + task: { + initialized: true, + completed: false, + }, + }, + }); + +describe("ChatInterface", () => { + it("should render the messages and input", () => { + renderChatInterface(); + expect(screen.queryAllByTestId("message")).toHaveLength(1); // initial welcome message only + }); + + it("should render the new message the user has typed", async () => { + renderChatInterface(); + + const input = screen.getByRole("textbox"); + + act(() => { + userEvent.type(input, "my message{enter}"); + }); + + expect(screen.getByText("my message")).toBeInTheDocument(); + }); + + it("should render user and assistant messages", () => { + const { store } = renderWithProviders(, { + preloadedState: { + chat: { + messages: [{ sender: "user", content: "Hello" }], + }, + }, + }); + + expect(screen.getAllByTestId("message")).toHaveLength(1); + expect(screen.getByText("Hello")).toBeInTheDocument(); + + act(() => { + store.dispatch(addAssistantMessage("Hello to you!")); + }); + + expect(screen.getAllByTestId("message")).toHaveLength(2); + expect(screen.getByText("Hello to you!")).toBeInTheDocument(); + }); + + it("should send the a start event to the Socket", () => { + renderWithProviders(, { + preloadedState: { + task: { + initialized: true, + completed: false, + }, + agent: { + curTaskState: AgentTaskState.INIT, + }, + }, + }); + + const input = screen.getByRole("textbox"); + act(() => { + userEvent.type(input, "my message{enter}"); + }); + + const event = { action: ActionType.START, args: { task: "my message" } }; + expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event)); + }); + + it("should send the a user message event to the Socket", () => { + renderWithProviders(, { + preloadedState: { + task: { + initialized: true, + completed: false, + }, + agent: { + curTaskState: AgentTaskState.AWAITING_USER_INPUT, + }, + }, + }); + + const input = screen.getByRole("textbox"); + act(() => { + userEvent.type(input, "my message{enter}"); + }); + + const event = { + action: ActionType.USER_MESSAGE, + args: { message: "my message" }, + }; + expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event)); + }); + + it("should disable the user input if agent is not initialized", () => { + renderWithProviders(, { + preloadedState: { + task: { + initialized: false, + completed: false, + }, + }, + }); + + const submitButton = screen.getByLabelText(/send message/i); + + expect(submitButton).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/chat/ChatInterface.tsx b/frontend/src/components/chat/ChatInterface.tsx new file mode 100644 index 00000000000..36be99b1bbc --- /dev/null +++ b/frontend/src/components/chat/ChatInterface.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { IoMdChatbubbles } from "react-icons/io"; +import ChatInput from "./ChatInput"; +import Chat from "./Chat"; +import { RootState } from "#/store"; +import AgentTaskState from "#/types/AgentTaskState"; +import { addUserMessage } from "#/state/chatSlice"; +import ActionType from "#/types/ActionType"; +import Socket from "#/services/socket"; + +function ChatInterface() { + const { initialized } = useSelector((state: RootState) => state.task); + const { messages } = useSelector((state: RootState) => state.chat); + const { curTaskState } = useSelector((state: RootState) => state.agent); + + const dispatch = useDispatch(); + + const handleSendMessage = (content: string) => { + dispatch(addUserMessage(content)); + + let event; + if (curTaskState === AgentTaskState.INIT) { + event = { action: ActionType.START, args: { task: content } }; + } else { + event = { action: ActionType.USER_MESSAGE, args: { message: content } }; + } + + Socket.send(JSON.stringify(event)); + }; + + return ( +
+
+ + Chat +
+
+
+ +
+ {/* Fade between messages and input */} +
+
+ +
+ ); +} + +export default ChatInterface; diff --git a/frontend/src/components/chat/ChatMessage.test.tsx b/frontend/src/components/chat/ChatMessage.test.tsx new file mode 100644 index 00000000000..e385386cc9d --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import React from "react"; +import ChatMessage from "./ChatMessage"; + +// avoid typing side-effect +vi.mock("#/hooks/useTyping", () => ({ + useTyping: vi.fn((text: string) => text), +})); + +describe("Message", () => { + it("should render a user message", () => { + render(); + + expect(screen.getByTestId("message")).toBeInTheDocument(); + expect(screen.getByTestId("message")).toHaveClass("self-end"); // user message should be on the right side + }); + + it("should render an assistant message", () => { + render(); + + expect(screen.getByTestId("message")).toBeInTheDocument(); + expect(screen.getByTestId("message")).not.toHaveClass("self-end"); // assistant message should be on the left side + }); + + it("should render markdown content", () => { + render( + , + ); + + // SyntaxHighlighter breaks the code blocks into "tokens" + expect(screen.getByText("console")).toBeInTheDocument(); + expect(screen.getByText("log")).toBeInTheDocument(); + expect(screen.getByText("'Hello'")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx new file mode 100644 index 00000000000..5490fb230e0 --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import Markdown from "react-markdown"; +import { twMerge } from "tailwind-merge"; +import { code } from "../markdown/code"; +import { useTyping } from "#/hooks/useTyping"; + +interface MessageProps { + message: Message; +} + +function ChatMessage({ message }: MessageProps) { + const text = useTyping(message.content); + + const className = twMerge( + "p-3 text-white max-w-[90%] overflow-y-auto rounded-lg", + message.sender === "user" ? "bg-neutral-700 self-end" : "bg-neutral-500", + ); + + return ( +
+ {text} +
+ ); +} + +export default ChatMessage; diff --git a/frontend/src/components/chat/message.d.ts b/frontend/src/components/chat/message.d.ts new file mode 100644 index 00000000000..6a3aa491678 --- /dev/null +++ b/frontend/src/components/chat/message.d.ts @@ -0,0 +1,4 @@ +type Message = { + sender: "user" | "assistant"; + content: string; +}; diff --git a/frontend/src/hooks/useTyping.test.ts b/frontend/src/hooks/useTyping.test.ts new file mode 100644 index 00000000000..dfe0719fb5a --- /dev/null +++ b/frontend/src/hooks/useTyping.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, it, vi } from "vitest"; +import { useTyping } from "./useTyping"; + +vi.useFakeTimers(); + +describe("useTyping", () => { + it("should 'type' a given message", () => { + const text = "Hello, World!"; + const typingSpeed = 10; + + const { result } = renderHook(() => useTyping(text)); + expect(result.current).toBe("H"); + + act(() => { + vi.advanceTimersByTime(typingSpeed); + }); + + expect(result.current).toBe("He"); + + act(() => { + vi.advanceTimersByTime(typingSpeed); + }); + + expect(result.current).toBe("Hel"); + + for (let i = 3; i < text.length; i += 1) { + act(() => { + vi.advanceTimersByTime(typingSpeed); + }); + } + + expect(result.current).toBe("Hello, World!"); + + act(() => { + vi.advanceTimersByTime(typingSpeed); + }); + + expect(result.current).toBe("Hello, World!"); + }); +}); diff --git a/frontend/src/hooks/useTyping.ts b/frontend/src/hooks/useTyping.ts new file mode 100644 index 00000000000..fc4e2f7191c --- /dev/null +++ b/frontend/src/hooks/useTyping.ts @@ -0,0 +1,23 @@ +import React from "react"; + +export const useTyping = (text: string) => { + const [message, setMessage] = React.useState(text[0]); + + const advance = () => + setTimeout(() => { + if (message.length < text.length) { + setMessage(text.slice(0, message.length + 1)); + } + }, 10); + + React.useEffect(() => { + const timeout = advance(); + + return () => { + clearTimeout(timeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [message]); + + return message; +}; diff --git a/frontend/src/hooks/useTypingEffect.test.ts b/frontend/src/hooks/useTypingEffect.test.ts deleted file mode 100644 index 18942d0b0a4..00000000000 --- a/frontend/src/hooks/useTypingEffect.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { useTypingEffect } from "./useTypingEffect"; - -describe("useTypingEffect", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.clearAllTimers(); - }); - - // This test fails because the hook improperly handles this case. - it.skip("should handle empty strings array", () => { - const { result } = renderHook(() => useTypingEffect([])); - - // Immediately check the result since there's nothing to type - expect(result.current).toBe("\u00A0"); // Non-breaking space - }); - - it("should type out a string correctly", () => { - const message = "Hello, world! This is a test message."; - - const { result } = renderHook(() => useTypingEffect([message])); - - // msg.length - 2 because the first two characters are typed immediately - // 100ms per character, 0.1 playbackRate - const msToRun = (message.length - 2) * 100 * 0.1; - - // Fast-forward time by to simulate typing message - act(() => { - vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing - }); - - expect(result.current).toBe(message.slice(0, -1)); - - act(() => { - vi.advanceTimersByTime(1); // include the last character - }); - - expect(result.current).toBe(message); - }); - - it("should type of a string correctly with a different playback rate", () => { - const message = "Hello, world! This is a test message."; - const playbackRate = 0.5; - - const { result } = renderHook(() => - useTypingEffect([message], { playbackRate }), - ); - - const msToRun = (message.length - 2) * 100 * playbackRate; - - act(() => { - vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing - }); - - expect(result.current).toBe(message.slice(0, -1)); - - act(() => { - vi.advanceTimersByTime(1); // include the last character - }); - - expect(result.current).toBe(message); - }); - - it("should loop through strings when multiple are provided", () => { - const messages = ["Hello", "World"]; - - const { result } = renderHook(() => useTypingEffect(messages)); - - const msToRunFirstString = messages[0].length * 100 * 0.1; - - // Fast-forward to end of first string - act(() => { - vi.advanceTimersByTime(msToRunFirstString); - }); - - expect(result.current).toBe(messages[0]); // Hello - - // Fast-forward through the delay and through the second string - act(() => { - // TODO: Improve to clarify the expected timing - vi.runAllTimers(); - }); - - expect(result.current).toBe(messages[1]); // World - }); - - it("should call setTypingActive with false when typing completes without loop", () => { - const setTypingActiveMock = vi.fn(); - - renderHook(() => - useTypingEffect(["Hello, world!", "This is a test message."], { - loop: false, - setTypingActive: setTypingActiveMock, - }), - ); - - expect(setTypingActiveMock).not.toHaveBeenCalled(); - - act(() => { - vi.runAllTimers(); - }); - - expect(setTypingActiveMock).toHaveBeenCalledWith(false); - expect(setTypingActiveMock).toHaveBeenCalledTimes(1); - }); - - it("should call addAssistantMessageToChat with the typeThis argument when typing completes without loop", () => { - const addAssistantMessageToChatMock = vi.fn(); - - renderHook(() => - useTypingEffect(["Hello, world!", "This is a test message."], { - loop: false, - // Note that only "Hello, world!" is typed out (the first string in the array) - typeThis: { content: "Hello, world!", sender: "assistant" }, - addAssistantMessageToChat: addAssistantMessageToChatMock, - }), - ); - - expect(addAssistantMessageToChatMock).not.toHaveBeenCalled(); - - act(() => { - vi.runAllTimers(); - }); - - expect(addAssistantMessageToChatMock).toHaveBeenCalledTimes(1); - expect(addAssistantMessageToChatMock).toHaveBeenCalledWith({ - content: "Hello, world!", - sender: "assistant", - }); - }); - - it("should call takeOneAndType when typing completes without loop", () => { - const takeOneAndTypeMock = vi.fn(); - - renderHook(() => - useTypingEffect(["Hello, world!", "This is a test message."], { - loop: false, - takeOneAndType: takeOneAndTypeMock, - }), - ); - - expect(takeOneAndTypeMock).not.toHaveBeenCalled(); - - act(() => { - vi.runAllTimers(); - }); - - expect(takeOneAndTypeMock).toHaveBeenCalledTimes(1); - }); - - // Implementation is not clear on how to handle this case - it.todo("should handle typing with loop"); -}); diff --git a/frontend/src/hooks/useTypingEffect.ts b/frontend/src/hooks/useTypingEffect.ts deleted file mode 100644 index af80eb098a0..00000000000 --- a/frontend/src/hooks/useTypingEffect.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect, useState } from "react"; -import { Message } from "#/state/chatSlice"; -/** - * hook to be used for typing chat effect - */ -export const useTypingEffect = ( - strings: string[] = [""], - { - loop = false, - playbackRate = 0.1, - setTypingActive = () => {}, - addAssistantMessageToChat = () => {}, - takeOneAndType = () => {}, - typeThis = { content: "", sender: "assistant" }, - }: { - loop?: boolean; - playbackRate?: number; - setTypingActive?: (bool: boolean) => void; - addAssistantMessageToChat?: (msg: Message) => void; - takeOneAndType?: () => void; - typeThis?: Message; - } = { - loop: false, - playbackRate: 0.1, - setTypingActive: () => {}, - addAssistantMessageToChat: () => {}, - takeOneAndType: () => {}, - typeThis: { content: "", sender: "assistant" }, - }, -) => { - // eslint-disable-next-line prefer-const - let [{ stringIndex, characterIndex }, setState] = useState<{ - stringIndex: number; - characterIndex: number; - }>({ - stringIndex: 0, - characterIndex: 0, - }); - - let timeoutId: number; - const emulateKeyStroke = () => { - // eslint-disable-next-line no-plusplus - characterIndex++; - if (characterIndex === strings[stringIndex].length) { - characterIndex = 0; - // eslint-disable-next-line no-plusplus - stringIndex++; - if (stringIndex === strings.length) { - if (!loop) { - setTypingActive(false); - addAssistantMessageToChat(typeThis); - takeOneAndType(); - return; - } - stringIndex = 0; - } - timeoutId = window.setTimeout(emulateKeyStroke, 100 * playbackRate); - } else if (characterIndex === strings[stringIndex].length - 1) { - timeoutId = window.setTimeout(emulateKeyStroke, 2000 * playbackRate); - } else { - timeoutId = window.setTimeout(emulateKeyStroke, 100 * playbackRate); - } - setState({ - characterIndex, - stringIndex, - }); - }; - - useEffect(() => { - emulateKeyStroke(); - return () => { - window.clearTimeout(timeoutId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const nonBreakingSpace = "\u00A0"; - return strings[stringIndex].slice(0, characterIndex + 1) || nonBreakingSpace; -}; diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts index 121ff0a678c..d4e8165c812 100644 --- a/frontend/src/services/actions.ts +++ b/frontend/src/services/actions.ts @@ -1,6 +1,6 @@ import { changeTaskState } from "#/state/agentSlice"; import { setScreenshotSrc, setUrl } from "#/state/browserSlice"; -import { appendAssistantMessage } from "#/state/chatSlice"; +import { addAssistantMessage } from "#/state/chatSlice"; import { setCode, updatePath } from "#/state/codeSlice"; import { appendInput } from "#/state/commandSlice"; import { appendJupyterInput } from "#/state/jupyterSlice"; @@ -28,23 +28,23 @@ const messageActions = { store.dispatch(setCode(content)); }, [ActionType.THINK]: (message: ActionMessage) => { - store.dispatch(appendAssistantMessage(message.args.thought)); + store.dispatch(addAssistantMessage(message.args.thought)); }, [ActionType.TALK]: (message: ActionMessage) => { - store.dispatch(appendAssistantMessage(message.args.content)); + store.dispatch(addAssistantMessage(message.args.content)); }, [ActionType.FINISH]: (message: ActionMessage) => { - store.dispatch(appendAssistantMessage(message.message)); + store.dispatch(addAssistantMessage(message.message)); }, [ActionType.RUN]: (message: ActionMessage) => { if (message.args.thought) { - store.dispatch(appendAssistantMessage(message.args.thought)); + store.dispatch(addAssistantMessage(message.args.thought)); } store.dispatch(appendInput(message.args.command)); }, [ActionType.RUN_IPYTHON]: (message: ActionMessage) => { if (message.args.thought) { - store.dispatch(appendAssistantMessage(message.args.thought)); + store.dispatch(addAssistantMessage(message.args.thought)); } store.dispatch(appendJupyterInput(message.args.code)); }, diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts index 8c01b0079f0..2ac75cf1bb4 100644 --- a/frontend/src/services/chatService.ts +++ b/frontend/src/services/chatService.ts @@ -1,18 +1,12 @@ -import { - Message, - appendToNewChatSequence, - appendUserMessage, - takeOneTypeIt, - toggleTypingActive, -} from "#/state/chatSlice"; import store from "#/store"; import ActionType from "#/types/ActionType"; import { SocketMessage } from "#/types/ResponseType"; import { ActionMessage } from "#/types/Message"; import Socket from "./socket"; +import { addUserMessage } from "#/state/chatSlice"; export function sendChatMessage(message: string, isTask: boolean = true): void { - store.dispatch(appendUserMessage(message)); + store.dispatch(addUserMessage(message)); let event; if (isTask) { event = { action: ActionType.START, args: { task: message } }; @@ -32,19 +26,9 @@ export function addChatMessageFromEvent(event: string | SocketMessage): void { data = event as ActionMessage; } if (data && data.args && data.args.task) { - store.dispatch(appendUserMessage(data.args.task)); + store.dispatch(addUserMessage(data.args.task)); } } catch (error) { // } } - -export function setTypingActive(bool: boolean): void { - store.dispatch(toggleTypingActive(bool)); -} -export function addAssistantMessageToChat(msg: Message): void { - store.dispatch(appendToNewChatSequence(msg)); -} -export function takeOneAndType(): void { - store.dispatch(takeOneTypeIt()); -} diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts index 956fc906697..2225693e2d5 100644 --- a/frontend/src/services/observations.ts +++ b/frontend/src/services/observations.ts @@ -1,10 +1,10 @@ -import { appendAssistantMessage } from "#/state/chatSlice"; import { setUrl, setScreenshotSrc } from "#/state/browserSlice"; import store from "#/store"; import { ObservationMessage } from "#/types/Message"; import { appendOutput } from "#/state/commandSlice"; import { appendJupyterOutput } from "#/state/jupyterSlice"; import ObservationType from "#/types/ObservationType"; +import { addAssistantMessage } from "#/state/chatSlice"; export function handleObservationMessage(message: ObservationMessage) { switch (message.observation) { @@ -24,7 +24,7 @@ export function handleObservationMessage(message: ObservationMessage) { } break; default: - store.dispatch(appendAssistantMessage(message.message)); + store.dispatch(addAssistantMessage(message.message)); break; } } diff --git a/frontend/src/state/chatSlice.ts b/frontend/src/state/chatSlice.ts index 292d606b7fe..1438103f912 100644 --- a/frontend/src/state/chatSlice.ts +++ b/frontend/src/state/chatSlice.ts @@ -1,86 +1,45 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -export type Message = { - content: string; - sender: "user" | "assistant"; +type SliceState = { messages: Message[] }; + +const initialState: SliceState = { + messages: [ + { + content: + "Hi! I'm OpenDevin, an AI Software Engineer. What would you like to build with me today?", + sender: "assistant", + }, + ], }; -const initialMessages: Message[] = [ - { - content: - "Hi! I'm OpenDevin, an AI Software Engineer. What would you like to build with me today?", - sender: "assistant", - }, -]; export const chatSlice = createSlice({ name: "chat", - initialState: { - messages: initialMessages, - typingActive: false, - userMessages: initialMessages, - assistantMessages: initialMessages, - assistantMessagesTypingQueue: [] as Message[], - newChatSequence: initialMessages, - typeThis: { content: "", sender: "assistant" } as Message, - }, + initialState, reducers: { - appendUserMessage: (state, action) => { - state.messages.push({ content: action.payload, sender: "user" }); - state.userMessages.push({ content: action.payload, sender: "user" }); - state.newChatSequence.push({ content: action.payload, sender: "user" }); - }, - appendAssistantMessage: (state, action) => { - state.messages.push({ content: action.payload, sender: "assistant" }); + addUserMessage(state, action: PayloadAction) { + const message: Message = { + sender: "user", + content: action.payload, + }; - if (state.assistantMessagesTypingQueue.length > 0 || state.typingActive) { - state.assistantMessagesTypingQueue.push({ - content: action.payload, - sender: "assistant", - }); - } else if ( - state.assistantMessagesTypingQueue.length === 0 && - !state.typingActive - ) { - state.typeThis = { - content: action.payload, - sender: "assistant", - }; - state.typingActive = true; - } + state.messages.push(message); }, - toggleTypingActive: (state, action) => { - state.typingActive = action.payload; - }, + addAssistantMessage(state, action: PayloadAction) { + const message: Message = { + sender: "assistant", + content: action.payload, + }; - appendToNewChatSequence: (state, action) => { - state.newChatSequence.push(action.payload); + state.messages.push(message); }, - takeOneTypeIt: (state) => { - if (state.assistantMessagesTypingQueue.length > 0) { - state.typeThis = state.assistantMessagesTypingQueue.shift() as Message; - } - }, - clearMessages: (state) => { - state.messages = initialMessages; - state.userMessages = initialMessages; - state.assistantMessages = initialMessages; - state.newChatSequence = initialMessages; - state.assistantMessagesTypingQueue = []; - state.typingActive = false; - state.typeThis = { content: "", sender: "assistant" }; + clearMessages(state) { + state.messages = []; }, }, }); -export const { - appendUserMessage, - appendAssistantMessage, - toggleTypingActive, - appendToNewChatSequence, - takeOneTypeIt, - clearMessages, -} = chatSlice.actions; - +export const { addUserMessage, addAssistantMessage, clearMessages } = + chatSlice.actions; export default chatSlice.reducer;