From 69091ed739dca5f4f6483b53076fd3ce91a34c45 Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:51:36 +0400 Subject: [PATCH 1/4] Refactor event handler --- frontend/src/components/event-handler.tsx | 193 ------------------ frontend/src/context/user-prefs-context.tsx | 7 + frontend/src/routes/_oh.app/event-handler.tsx | 12 ++ .../hooks/use-handle-runtime-active.ts | 65 ++++++ .../_oh.app/hooks/use-handle-ws-events.ts | 71 +++++++ .../_oh.app/hooks/use-ws-status-change.ts | 97 +++++++++ .../routes/{_oh.app.tsx => _oh.app/route.tsx} | 6 +- 7 files changed, 255 insertions(+), 196 deletions(-) delete mode 100644 frontend/src/components/event-handler.tsx create mode 100644 frontend/src/routes/_oh.app/event-handler.tsx create mode 100644 frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts create mode 100644 frontend/src/routes/_oh.app/hooks/use-handle-ws-events.ts create mode 100644 frontend/src/routes/_oh.app/hooks/use-ws-status-change.ts rename frontend/src/routes/{_oh.app.tsx => _oh.app/route.tsx} (95%) diff --git a/frontend/src/components/event-handler.tsx b/frontend/src/components/event-handler.tsx deleted file mode 100644 index 13c769582658..000000000000 --- a/frontend/src/components/event-handler.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import React from "react"; -import { useDispatch, useSelector } from "react-redux"; -import toast from "react-hot-toast"; -import posthog from "posthog-js"; -import { - useWsClient, - WsClientProviderStatus, -} from "#/context/ws-client-provider"; -import { ErrorObservation } from "#/types/core/observations"; -import { addErrorMessage, addUserMessage } from "#/state/chat-slice"; -import { - getCloneRepoCommand, - getGitHubTokenCommand, -} from "#/services/terminal-service"; -import { - clearFiles, - clearInitialQuery, - clearSelectedRepository, - setImportedProjectZip, -} from "#/state/initial-query-slice"; -import store, { RootState } from "#/store"; -import { createChatMessage } from "#/services/chat-service"; -import { isGitHubErrorReponse } from "#/api/github"; -import { base64ToBlob } from "#/utils/base64-to-blob"; -import { setCurrentAgentState } from "#/state/agent-slice"; -import AgentState from "#/types/agent-state"; -import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; -import { useGitHubUser } from "#/hooks/query/use-github-user"; -import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; -import { useAuth } from "#/context/auth-context"; -import { useEndSession } from "#/hooks/use-end-session"; -import { useUserPrefs } from "#/context/user-prefs-context"; - -interface ServerError { - error: boolean | string; - message: string; - [key: string]: unknown; -} - -const isServerError = (data: object): data is ServerError => "error" in data; - -const isErrorObservation = (data: object): data is ErrorObservation => - "observation" in data && data.observation === "error"; - -export function EventHandler({ children }: React.PropsWithChildren) { - const { setToken, gitHubToken } = useAuth(); - const { settings } = useUserPrefs(); - const { events, status, send } = useWsClient(); - const statusRef = React.useRef(null); - const runtimeActive = status === WsClientProviderStatus.ACTIVE; - const dispatch = useDispatch(); - const { files, importedProjectZip, initialQuery } = useSelector( - (state: RootState) => state.initalQuery, - ); - const endSession = useEndSession(); - - // FIXME: Bad practice - should be handled with state - const { selectedRepository } = useSelector( - (state: RootState) => state.initalQuery, - ); - - const { data: user } = useGitHubUser(); - const { mutate: uploadFiles } = useUploadFiles(); - - const sendInitialQuery = (query: string, base64Files: string[]) => { - const timestamp = new Date().toISOString(); - send(createChatMessage(query, base64Files, timestamp)); - }; - const userId = React.useMemo(() => { - if (user && !isGitHubErrorReponse(user)) return user.id; - return null; - }, [user]); - - React.useEffect(() => { - if (!events.length) { - return; - } - const event = events[events.length - 1]; - if (event.token && typeof event.token === "string") { - setToken(event.token); - return; - } - - if (isServerError(event)) { - if (event.error_code === 401) { - toast.error("Session expired."); - endSession(); - return; - } - - if (typeof event.error === "string") { - toast.error(event.error); - } else { - toast.error(event.message); - } - return; - } - - if (event.type === "error") { - const message: string = `${event.message}`; - if (message.startsWith("Agent reached maximum")) { - // We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations - send(generateAgentStateChangeEvent(AgentState.PAUSED)); - } - } - - if (isErrorObservation(event)) { - dispatch( - addErrorMessage({ - id: event.extras?.error_id, - message: event.message, - }), - ); - } - }, [events.length]); - - React.useEffect(() => { - if (statusRef.current === status) { - return; // This is a check because of strict mode - if the status did not change, don't do anything - } - statusRef.current = status; - - if (status === WsClientProviderStatus.ACTIVE) { - let additionalInfo = ""; - if (gitHubToken && selectedRepository) { - send(getCloneRepoCommand(gitHubToken, selectedRepository)); - additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`; - dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'? - } - // if there's an uploaded project zip, add it to the chat - else if (importedProjectZip) { - additionalInfo = `Files have been uploaded. Please check the /workspace for files.`; - } - - if (initialQuery) { - if (additionalInfo) { - sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files); - } else { - sendInitialQuery(initialQuery, files); - } - dispatch(clearFiles()); // reset selected files - dispatch(clearInitialQuery()); // reset initial query - } - } - - if (status === WsClientProviderStatus.OPENING && initialQuery) { - dispatch( - addUserMessage({ - content: initialQuery, - imageUrls: files, - timestamp: new Date().toISOString(), - }), - ); - } - - if (status === WsClientProviderStatus.STOPPED) { - store.dispatch(setCurrentAgentState(AgentState.STOPPED)); - } - }, [status]); - - React.useEffect(() => { - if (runtimeActive && userId && gitHubToken) { - // Export if the user valid, this could happen mid-session so it is handled here - send(getGitHubTokenCommand(gitHubToken)); - } - }, [userId, gitHubToken, runtimeActive]); - - React.useEffect(() => { - if (runtimeActive && importedProjectZip) { - const blob = base64ToBlob(importedProjectZip); - const file = new File([blob], "imported-project.zip", { - type: blob.type, - }); - uploadFiles( - { files: [file] }, - { - onError: () => { - toast.error("Failed to upload project files."); - }, - }, - ); - dispatch(setImportedProjectZip(null)); - } - }, [runtimeActive, importedProjectZip]); - - React.useEffect(() => { - if (settings.LLM_API_KEY) { - posthog.capture("user_activated"); - } - }, [settings.LLM_API_KEY]); - - return children; -} diff --git a/frontend/src/context/user-prefs-context.tsx b/frontend/src/context/user-prefs-context.tsx index e3573c9234c0..060749463da5 100644 --- a/frontend/src/context/user-prefs-context.tsx +++ b/frontend/src/context/user-prefs-context.tsx @@ -1,4 +1,5 @@ import React from "react"; +import posthog from "posthog-js"; import { getSettings, Settings, @@ -28,6 +29,12 @@ function UserPrefsProvider({ children }: React.PropsWithChildren) { setSettingsAreUpToDate(checkIfSettingsAreUpToDate()); }; + React.useEffect(() => { + if (settings.LLM_API_KEY) { + posthog.capture("user_activated"); + } + }, [settings.LLM_API_KEY]); + const value = React.useMemo( () => ({ settings, diff --git a/frontend/src/routes/_oh.app/event-handler.tsx b/frontend/src/routes/_oh.app/event-handler.tsx new file mode 100644 index 000000000000..2c45f013e2f7 --- /dev/null +++ b/frontend/src/routes/_oh.app/event-handler.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { useWSStatusChange } from "./hooks/use-ws-status-change"; +import { useHandleWSEvents } from "./hooks/use-handle-ws-events"; +import { useHandleRuntimeActive } from "./hooks/use-handle-runtime-active"; + +export function EventHandler({ children }: React.PropsWithChildren) { + useWSStatusChange(); + useHandleWSEvents(); + useHandleRuntimeActive(); + + return children; +} diff --git a/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts b/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts new file mode 100644 index 000000000000..e5162f4dc0de --- /dev/null +++ b/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts @@ -0,0 +1,65 @@ +import React from "react"; +import toast from "react-hot-toast"; +import { useDispatch, useSelector } from "react-redux"; +import { isGitHubErrorReponse } from "#/api/github"; +import { useAuth } from "#/context/auth-context"; +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; +import { getGitHubTokenCommand } from "#/services/terminal-service"; +import { setImportedProjectZip } from "#/state/initial-query-slice"; +import { RootState } from "#/store"; +import { base64ToBlob } from "#/utils/base64-to-blob"; +import { useUploadFiles } from "../../../hooks/mutation/use-upload-files"; +import { useGitHubUser } from "../../../hooks/query/use-github-user"; + +export const useHandleRuntimeActive = () => { + const { gitHubToken } = useAuth(); + const { status, send } = useWsClient(); + + const dispatch = useDispatch(); + + const { data: user } = useGitHubUser(); + const { mutate: uploadFiles } = useUploadFiles(); + + const runtimeActive = status === WsClientProviderStatus.ACTIVE; + + const { importedProjectZip } = useSelector( + (state: RootState) => state.initalQuery, + ); + + const userId = React.useMemo(() => { + if (user && !isGitHubErrorReponse(user)) return user.id; + return null; + }, [user]); + + const handleUploadFiles = (zip: string) => { + const blob = base64ToBlob(zip); + const file = new File([blob], "imported-project.zip", { + type: blob.type, + }); + uploadFiles( + { files: [file] }, + { + onError: () => { + toast.error("Failed to upload project files."); + }, + }, + ); + dispatch(setImportedProjectZip(null)); + }; + + React.useEffect(() => { + if (runtimeActive && userId && gitHubToken) { + // Export if the user valid, this could happen mid-session so it is handled here + send(getGitHubTokenCommand(gitHubToken)); + } + }, [userId, gitHubToken, runtimeActive]); + + React.useEffect(() => { + if (runtimeActive && importedProjectZip) { + handleUploadFiles(importedProjectZip); + } + }, [runtimeActive, importedProjectZip]); +}; diff --git a/frontend/src/routes/_oh.app/hooks/use-handle-ws-events.ts b/frontend/src/routes/_oh.app/hooks/use-handle-ws-events.ts new file mode 100644 index 000000000000..5dfc7f8ca8b7 --- /dev/null +++ b/frontend/src/routes/_oh.app/hooks/use-handle-ws-events.ts @@ -0,0 +1,71 @@ +import React from "react"; +import toast from "react-hot-toast"; +import { useDispatch } from "react-redux"; +import { useAuth } from "#/context/auth-context"; +import { useWsClient } from "#/context/ws-client-provider"; +import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; +import { addErrorMessage } from "#/state/chat-slice"; +import AgentState from "#/types/agent-state"; +import { ErrorObservation } from "#/types/core/observations"; +import { useEndSession } from "../../../hooks/use-end-session"; + +interface ServerError { + error: boolean | string; + message: string; + [key: string]: unknown; +} + +const isServerError = (data: object): data is ServerError => "error" in data; + +const isErrorObservation = (data: object): data is ErrorObservation => + "observation" in data && data.observation === "error"; + +export const useHandleWSEvents = () => { + const { events, send } = useWsClient(); + const { setToken } = useAuth(); + const endSession = useEndSession(); + const dispatch = useDispatch(); + + React.useEffect(() => { + if (!events.length) { + return; + } + const event = events[events.length - 1]; + if (event.token && typeof event.token === "string") { + setToken(event.token); + return; + } + + if (isServerError(event)) { + if (event.error_code === 401) { + toast.error("Session expired."); + endSession(); + return; + } + + if (typeof event.error === "string") { + toast.error(event.error); + } else { + toast.error(event.message); + } + return; + } + + if (event.type === "error") { + const message: string = `${event.message}`; + if (message.startsWith("Agent reached maximum")) { + // We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations + send(generateAgentStateChangeEvent(AgentState.PAUSED)); + } + } + + if (isErrorObservation(event)) { + dispatch( + addErrorMessage({ + id: event.extras?.error_id, + message: event.message, + }), + ); + } + }, [events.length]); +}; diff --git a/frontend/src/routes/_oh.app/hooks/use-ws-status-change.ts b/frontend/src/routes/_oh.app/hooks/use-ws-status-change.ts new file mode 100644 index 000000000000..a93a5c584d0f --- /dev/null +++ b/frontend/src/routes/_oh.app/hooks/use-ws-status-change.ts @@ -0,0 +1,97 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useAuth } from "#/context/auth-context"; +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; +import { createChatMessage } from "#/services/chat-service"; +import { getCloneRepoCommand } from "#/services/terminal-service"; +import { setCurrentAgentState } from "#/state/agent-slice"; +import { addUserMessage } from "#/state/chat-slice"; +import { + clearSelectedRepository, + clearFiles, + clearInitialQuery, +} from "#/state/initial-query-slice"; +import { RootState } from "#/store"; +import AgentState from "#/types/agent-state"; + +export const useWSStatusChange = () => { + const { send, status } = useWsClient(); + const { gitHubToken } = useAuth(); + const dispatch = useDispatch(); + + const statusRef = React.useRef(null); + + const { selectedRepository } = useSelector( + (state: RootState) => state.initalQuery, + ); + + const { files, importedProjectZip, initialQuery } = useSelector( + (state: RootState) => state.initalQuery, + ); + + const sendInitialQuery = (query: string, base64Files: string[]) => { + const timestamp = new Date().toISOString(); + send(createChatMessage(query, base64Files, timestamp)); + }; + + const dispatchCloneRepoCommand = (ghToken: string, repository: string) => { + send(getCloneRepoCommand(ghToken, repository)); + dispatch(clearSelectedRepository()); + }; + + const dispatchInitialQuery = (query: string, additionalInfo: string) => { + if (additionalInfo) { + sendInitialQuery(`${query}\n\n[${additionalInfo}]`, files); + } else { + sendInitialQuery(query, files); + } + + dispatch(clearFiles()); // reset selected files + dispatch(clearInitialQuery()); // reset initial query + }; + + const handleOnWSActive = () => { + let additionalInfo = ""; + + if (gitHubToken && selectedRepository) { + dispatchCloneRepoCommand(gitHubToken, selectedRepository); + additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`; + } else if (importedProjectZip) { + // if there's an uploaded project zip, add it to the chat + additionalInfo = + "Files have been uploaded. Please check the /workspace for files."; + } + + if (initialQuery) { + dispatchInitialQuery(initialQuery, additionalInfo); + } + }; + + React.useEffect(() => { + if (statusRef.current === status) { + return; // This is a check because of strict mode - if the status did not change, don't do anything + } + statusRef.current = status; + + if (status === WsClientProviderStatus.ACTIVE) { + handleOnWSActive(); + } + + if (status === WsClientProviderStatus.OPENING && initialQuery) { + dispatch( + addUserMessage({ + content: initialQuery, + imageUrls: files, + timestamp: new Date().toISOString(), + }), + ); + } + + if (status === WsClientProviderStatus.STOPPED) { + dispatch(setCurrentAgentState(AgentState.STOPPED)); + } + }, [status]); +}; diff --git a/frontend/src/routes/_oh.app.tsx b/frontend/src/routes/_oh.app/route.tsx similarity index 95% rename from frontend/src/routes/_oh.app.tsx rename to frontend/src/routes/_oh.app/route.tsx index 3b672508a2ef..90134a9a098b 100644 --- a/frontend/src/routes/_oh.app.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -2,7 +2,7 @@ import { useDisclosure } from "@nextui-org/react"; import React from "react"; import { Outlet } from "@remix-run/react"; import { useDispatch, useSelector } from "react-redux"; -import Security from "../components/modals/security/security"; +import Security from "#/components/modals/security/security"; import { Controls } from "#/components/controls"; import { RootState } from "#/store"; import { Container } from "#/components/container"; @@ -16,7 +16,7 @@ import { clearJupyter } from "#/state/jupyter-slice"; import { FilesProvider } from "#/context/files"; import { ChatInterface } from "#/components/chat-interface"; import { WsClientProvider } from "#/context/ws-client-provider"; -import { EventHandler } from "#/components/event-handler"; +import { EventHandler } from "./event-handler"; import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit"; import { useAuth } from "#/context/auth-context"; import { useUserPrefs } from "#/context/user-prefs-context"; @@ -41,7 +41,7 @@ function App() { ); const Terminal = React.useMemo( - () => React.lazy(() => import("../components/terminal/terminal")), + () => React.lazy(() => import("#/components/terminal/terminal")), [], ); From ca3d54070b1bb4f0504eacf3accfc63d3aef28e8 Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:23:41 +0400 Subject: [PATCH 2/4] Refactor chat interface --- frontend/src/components/chat-interface.tsx | 278 ------------------ .../hooks/query/use-conversation-config.ts | 32 ++ .../src/routes/_oh.app/action-suggestions.tsx | 90 ++++++ .../src/routes/_oh.app/chat-interface.tsx | 144 +++++++++ .../src/routes/_oh.app/chat-suggestions.tsx | 29 ++ .../src/routes/_oh.app/loading-spinner.tsx | 7 + frontend/src/routes/_oh.app/messages.tsx | 33 +++ frontend/src/routes/_oh.app/route.tsx | 3 + 8 files changed, 338 insertions(+), 278 deletions(-) delete mode 100644 frontend/src/components/chat-interface.tsx create mode 100644 frontend/src/hooks/query/use-conversation-config.ts create mode 100644 frontend/src/routes/_oh.app/action-suggestions.tsx create mode 100644 frontend/src/routes/_oh.app/chat-interface.tsx create mode 100644 frontend/src/routes/_oh.app/chat-suggestions.tsx create mode 100644 frontend/src/routes/_oh.app/loading-spinner.tsx create mode 100644 frontend/src/routes/_oh.app/messages.tsx diff --git a/frontend/src/components/chat-interface.tsx b/frontend/src/components/chat-interface.tsx deleted file mode 100644 index 6d12b9cc6baa..000000000000 --- a/frontend/src/components/chat-interface.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; -import React from "react"; -import posthog from "posthog-js"; -import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; -import { ChatMessage } from "./chat-message"; -import { FeedbackActions } from "./feedback-actions"; -import { ImageCarousel } from "./image-carousel"; -import { createChatMessage } from "#/services/chat-service"; -import { InteractiveChatBox } from "./interactive-chat-box"; -import { addUserMessage } from "#/state/chat-slice"; -import { RootState } from "#/store"; -import AgentState from "#/types/agent-state"; -import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; -import { FeedbackModal } from "./feedback-modal"; -import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; -import TypingIndicator from "./chat/typing-indicator"; -import ConfirmationButtons from "./chat/confirmation-buttons"; -import { ErrorMessage } from "./error-message"; -import { ContinueButton } from "./continue-button"; -import { ScrollToBottomButton } from "./scroll-to-bottom-button"; -import { Suggestions } from "./suggestions"; -import { SUGGESTIONS } from "#/utils/suggestions"; -import BuildIt from "#/icons/build-it.svg?react"; -import { - useWsClient, - WsClientProviderStatus, -} from "#/context/ws-client-provider"; -import OpenHands from "#/api/open-hands"; -import { downloadWorkspace } from "#/utils/download-workspace"; -import { SuggestionItem } from "./suggestion-item"; -import { useAuth } from "#/context/auth-context"; - -const isErrorMessage = ( - message: Message | ErrorMessage, -): message is ErrorMessage => "error" in message; - -export function ChatInterface() { - const { gitHubToken } = useAuth(); - const { send, status, isLoadingMessages } = useWsClient(); - - const dispatch = useDispatch(); - const scrollRef = React.useRef(null); - const { scrollDomToBottom, onChatBodyScroll, hitBottom } = - useScrollToBottom(scrollRef); - - const { messages } = useSelector((state: RootState) => state.chat); - const { curAgentState } = useSelector((state: RootState) => state.agent); - - const [feedbackPolarity, setFeedbackPolarity] = React.useState< - "positive" | "negative" - >("positive"); - const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false); - const [messageToSend, setMessageToSend] = React.useState(null); - const [isDownloading, setIsDownloading] = React.useState(false); - const [hasPullRequest, setHasPullRequest] = React.useState(false); - - React.useEffect(() => { - if (status === WsClientProviderStatus.ACTIVE) { - try { - OpenHands.getRuntimeId().then(({ runtime_id }) => { - // eslint-disable-next-line no-console - console.log( - "Runtime ID: %c%s", - "background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;", - runtime_id, - ); - }); - } catch (e) { - console.warn("Runtime ID not available in this environment"); - } - } - }, [status]); - - const handleSendMessage = async (content: string, files: File[]) => { - posthog.capture("user_message_sent", { - current_message_count: messages.length, - }); - const promises = files.map((file) => convertImageToBase64(file)); - const imageUrls = await Promise.all(promises); - - const timestamp = new Date().toISOString(); - dispatch(addUserMessage({ content, imageUrls, timestamp })); - send(createChatMessage(content, imageUrls, timestamp)); - setMessageToSend(null); - }; - - const handleStop = () => { - posthog.capture("stop_button_clicked"); - send(generateAgentStateChangeEvent(AgentState.STOPPED)); - }; - - const handleSendContinueMsg = () => { - handleSendMessage("Continue", []); - }; - - const onClickShareFeedbackActionButton = async ( - polarity: "positive" | "negative", - ) => { - setFeedbackModalIsOpen(true); - setFeedbackPolarity(polarity); - }; - - const handleDownloadWorkspace = async () => { - setIsDownloading(true); - try { - await downloadWorkspace(); - } catch (error) { - // TODO: Handle error - } finally { - setIsDownloading(false); - } - }; - - return ( -
- {messages.length === 0 && ( -
-
- - - Let's start building! - -
- ({ - label, - value, - }))} - onSuggestionClick={(value) => { - setMessageToSend(value); - }} - /> -
- )} - -
onChatBodyScroll(e.currentTarget)} - className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2" - > - {isLoadingMessages && ( -
-
-
- )} - - {!isLoadingMessages && - messages.map((message, index) => - isErrorMessage(message) ? ( - - ) : ( - - {message.imageUrls.length > 0 && ( - - )} - {messages.length - 1 === index && - message.sender === "assistant" && - curAgentState === AgentState.AWAITING_USER_CONFIRMATION && ( - - )} - - ), - )} - - {(curAgentState === AgentState.AWAITING_USER_INPUT || - curAgentState === AgentState.FINISHED) && ( -
- {gitHubToken ? ( -
- {!hasPullRequest ? ( - <> - { - posthog.capture("push_to_branch_button_clicked"); - handleSendMessage(value, []); - }} - /> - { - posthog.capture("create_pr_button_clicked"); - handleSendMessage(value, []); - setHasPullRequest(true); - }} - /> - - ) : ( - { - posthog.capture("push_to_pr_button_clicked"); - handleSendMessage(value, []); - }} - /> - )} -
- ) : ( - { - posthog.capture("download_workspace_button_clicked"); - handleDownloadWorkspace(); - }} - /> - )} -
- )} -
- -
-
- - onClickShareFeedbackActionButton("positive") - } - onNegativeFeedback={() => - onClickShareFeedbackActionButton("negative") - } - /> -
- {messages.length > 2 && - curAgentState === AgentState.AWAITING_USER_INPUT && ( - - )} - {curAgentState === AgentState.RUNNING && } -
- {!hitBottom && } -
- - -
- - setFeedbackModalIsOpen(false)} - polarity={feedbackPolarity} - /> -
- ); -} diff --git a/frontend/src/hooks/query/use-conversation-config.ts b/frontend/src/hooks/query/use-conversation-config.ts new file mode 100644 index 000000000000..729c6c969d13 --- /dev/null +++ b/frontend/src/hooks/query/use-conversation-config.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; +import OpenHands from "#/api/open-hands"; + +export const useConversationConfig = () => { + const { status } = useWsClient(); + + const query = useQuery({ + queryKey: ["conversation_config"], + queryFn: OpenHands.getRuntimeId, + enabled: status === WsClientProviderStatus.ACTIVE, + }); + + React.useEffect(() => { + if (query.data) { + const { runtime_id: runtimeId } = query.data; + + // eslint-disable-next-line no-console + console.log( + "Runtime ID: %c%s", + "background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;", + runtimeId, + ); + } + }, [query.data]); + + return query; +}; diff --git a/frontend/src/routes/_oh.app/action-suggestions.tsx b/frontend/src/routes/_oh.app/action-suggestions.tsx new file mode 100644 index 000000000000..fc9442e99606 --- /dev/null +++ b/frontend/src/routes/_oh.app/action-suggestions.tsx @@ -0,0 +1,90 @@ +import posthog from "posthog-js"; +import React from "react"; +import { SuggestionItem } from "#/components/suggestion-item"; +import { useAuth } from "#/context/auth-context"; +import { downloadWorkspace } from "#/utils/download-workspace"; + +interface ActionSuggestionsProps { + onSuggestionsClick: (value: string) => void; +} + +export function ActionSuggestions({ + onSuggestionsClick, +}: ActionSuggestionsProps) { + const { gitHubToken } = useAuth(); + + const [isDownloading, setIsDownloading] = React.useState(false); + const [hasPullRequest, setHasPullRequest] = React.useState(false); + + const handleDownloadWorkspace = async () => { + setIsDownloading(true); + try { + await downloadWorkspace(); + } catch (error) { + // TODO: Handle error + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ {gitHubToken ? ( +
+ {!hasPullRequest ? ( + <> + { + posthog.capture("push_to_branch_button_clicked"); + onSuggestionsClick(value); + }} + /> + { + posthog.capture("create_pr_button_clicked"); + onSuggestionsClick(value); + setHasPullRequest(true); + }} + /> + + ) : ( + { + posthog.capture("push_to_pr_button_clicked"); + onSuggestionsClick(value); + }} + /> + )} +
+ ) : ( + { + posthog.capture("download_workspace_button_clicked"); + handleDownloadWorkspace(); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/routes/_oh.app/chat-interface.tsx b/frontend/src/routes/_oh.app/chat-interface.tsx new file mode 100644 index 000000000000..8b413c550fdd --- /dev/null +++ b/frontend/src/routes/_oh.app/chat-interface.tsx @@ -0,0 +1,144 @@ +import { useDispatch, useSelector } from "react-redux"; +import React from "react"; +import posthog from "posthog-js"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; +import { FeedbackActions } from "../../components/feedback-actions"; +import { createChatMessage } from "#/services/chat-service"; +import { InteractiveChatBox } from "../../components/interactive-chat-box"; +import { addUserMessage } from "#/state/chat-slice"; +import { RootState } from "#/store"; +import AgentState from "#/types/agent-state"; +import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; +import { FeedbackModal } from "../../components/feedback-modal"; +import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; +import TypingIndicator from "../../components/chat/typing-indicator"; +import { ContinueButton } from "../../components/continue-button"; +import { ScrollToBottomButton } from "../../components/scroll-to-bottom-button"; +import { useWsClient } from "#/context/ws-client-provider"; +import { Messages } from "./messages"; +import { LoadingSpinner } from "./loading-spinner"; +import { ChatSuggestions } from "./chat-suggestions"; +import { ActionSuggestions } from "./action-suggestions"; + +export function ChatInterface() { + const { send, isLoadingMessages } = useWsClient(); + const dispatch = useDispatch(); + + const scrollRef = React.useRef(null); + const { scrollDomToBottom, onChatBodyScroll, hitBottom } = + useScrollToBottom(scrollRef); + + const { messages } = useSelector((state: RootState) => state.chat); + const { curAgentState } = useSelector((state: RootState) => state.agent); + + const [feedbackPolarity, setFeedbackPolarity] = React.useState< + "positive" | "negative" + >("positive"); + const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false); + const [messageToSend, setMessageToSend] = React.useState(null); + + const handleSendMessage = async (content: string, files: File[]) => { + posthog.capture("user_message_sent", { + current_message_count: messages.length, + }); + const promises = files.map((file) => convertImageToBase64(file)); + const imageUrls = await Promise.all(promises); + + const timestamp = new Date().toISOString(); + dispatch(addUserMessage({ content, imageUrls, timestamp })); + send(createChatMessage(content, imageUrls, timestamp)); + setMessageToSend(null); + }; + + const handleStop = () => { + posthog.capture("stop_button_clicked"); + send(generateAgentStateChangeEvent(AgentState.STOPPED)); + }; + + const handleSendContinueMsg = () => { + handleSendMessage("Continue", []); + }; + + const onClickShareFeedbackActionButton = async ( + polarity: "positive" | "negative", + ) => { + setFeedbackModalIsOpen(true); + setFeedbackPolarity(polarity); + }; + + const isWaitingForUserInput = + curAgentState === AgentState.AWAITING_USER_INPUT || + curAgentState === AgentState.FINISHED; + + return ( +
+ {messages.length === 0 && ( + + )} + +
onChatBodyScroll(e.currentTarget)} + className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2" + > + {isLoadingMessages && } + + {!isLoadingMessages && ( + + )} + + {isWaitingForUserInput && ( + handleSendMessage(value, [])} + /> + )} +
+ +
+
+ + onClickShareFeedbackActionButton("positive") + } + onNegativeFeedback={() => + onClickShareFeedbackActionButton("negative") + } + /> + +
+ {messages.length > 2 && + curAgentState === AgentState.AWAITING_USER_INPUT && ( + + )} + {curAgentState === AgentState.RUNNING && } +
+ + {!hitBottom && } +
+ + +
+ + setFeedbackModalIsOpen(false)} + polarity={feedbackPolarity} + /> +
+ ); +} diff --git a/frontend/src/routes/_oh.app/chat-suggestions.tsx b/frontend/src/routes/_oh.app/chat-suggestions.tsx new file mode 100644 index 000000000000..1f08ace538f7 --- /dev/null +++ b/frontend/src/routes/_oh.app/chat-suggestions.tsx @@ -0,0 +1,29 @@ +import { Suggestions } from "#/components/suggestions"; +import BuildIt from "#/icons/build-it.svg?react"; +import { SUGGESTIONS } from "#/utils/suggestions"; + +interface ChatSuggestionsProps { + onSuggestionsClick: (value: string) => void; +} + +export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) { + return ( +
+
+ + + Let's start building! + +
+ ({ + label, + value, + }))} + onSuggestionClick={onSuggestionsClick} + /> +
+ ); +} diff --git a/frontend/src/routes/_oh.app/loading-spinner.tsx b/frontend/src/routes/_oh.app/loading-spinner.tsx new file mode 100644 index 000000000000..ba119cb20c6b --- /dev/null +++ b/frontend/src/routes/_oh.app/loading-spinner.tsx @@ -0,0 +1,7 @@ +export function LoadingSpinner() { + return ( +
+
+
+ ); +} diff --git a/frontend/src/routes/_oh.app/messages.tsx b/frontend/src/routes/_oh.app/messages.tsx new file mode 100644 index 000000000000..3766aac33103 --- /dev/null +++ b/frontend/src/routes/_oh.app/messages.tsx @@ -0,0 +1,33 @@ +import { ChatMessage } from "#/components/chat-message"; +import ConfirmationButtons from "#/components/chat/confirmation-buttons"; +import { ErrorMessage } from "#/components/error-message"; +import { ImageCarousel } from "#/components/image-carousel"; + +const isErrorMessage = ( + message: Message | ErrorMessage, +): message is ErrorMessage => "error" in message; + +interface MessagesProps { + messages: (Message | ErrorMessage)[]; + isAwaitingUserConfirmation: boolean; +} + +export function Messages({ + messages, + isAwaitingUserConfirmation, +}: MessagesProps) { + return messages.map((message, index) => + isErrorMessage(message) ? ( + + ) : ( + + {message.imageUrls.length > 0 && ( + + )} + {messages.length - 1 === index && + message.sender === "assistant" && + isAwaitingUserConfirmation && } + + ), + ); +} diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 90134a9a098b..fa38c38658f4 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -20,6 +20,7 @@ import { EventHandler } from "./event-handler"; import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit"; import { useAuth } from "#/context/auth-context"; import { useUserPrefs } from "#/context/user-prefs-context"; +import { useConversationConfig } from "#/hooks/query/use-conversation-config"; function App() { const { token, gitHubToken } = useAuth(); @@ -27,6 +28,8 @@ function App() { const dispatch = useDispatch(); + useConversationConfig(); + const { selectedRepository } = useSelector( (state: RootState) => state.initalQuery, ); From 9b6b04f0ccec2bb050fb4982c1c67e926a98fd8f Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:24:28 +0400 Subject: [PATCH 3/4] Minor refactor --- frontend/src/routes/_oh.app/route.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index fa38c38658f4..5a0fc655e9c9 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -14,7 +14,7 @@ import GlobeIcon from "#/icons/globe.svg?react"; import ListIcon from "#/icons/list-type-number.svg?react"; import { clearJupyter } from "#/state/jupyter-slice"; import { FilesProvider } from "#/context/files"; -import { ChatInterface } from "#/components/chat-interface"; +import { ChatInterface } from "./chat-interface"; import { WsClientProvider } from "#/context/ws-client-provider"; import { EventHandler } from "./event-handler"; import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit"; @@ -27,7 +27,6 @@ function App() { const { settings } = useUserPrefs(); const dispatch = useDispatch(); - useConversationConfig(); const { selectedRepository } = useSelector( From ad4d468bfade5e15a55bea1b6e460fdd7316159b Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Tue, 26 Nov 2024 20:33:52 +0400 Subject: [PATCH 4/4] Fix import --- .../components/chat/chat-interface.test.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 0953f43353ed..8596d3acd3d7 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -2,11 +2,11 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; -import { ChatInterface } from "#/components/chat-interface"; import { addUserMessage } from "#/state/chat-slice"; import { SUGGESTIONS } from "#/utils/suggestions"; import * as ChatSlice from "#/state/chat-slice"; import { WsClientProviderStatus } from "#/context/ws-client-provider"; +import { ChatInterface } from "#/routes/_oh.app/chat-interface"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const renderChatInterface = (messages: (Message | ErrorMessage)[]) => @@ -18,7 +18,11 @@ describe("Empty state", () => { })); const { useWsClient: useWsClientMock } = vi.hoisted(() => ({ - useWsClient: vi.fn(() => ({ send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false })), + useWsClient: vi.fn(() => ({ + send: sendMock, + status: WsClientProviderStatus.ACTIVE, + isLoadingMessages: false, + })), })); beforeAll(() => { @@ -84,7 +88,9 @@ describe("Empty state", () => { async () => { // this is to test that the message is in the UI before the socket is called useWsClientMock.mockImplementation(() => ({ - send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false + send: sendMock, + status: WsClientProviderStatus.ACTIVE, + isLoadingMessages: false, })); const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage"); const user = userEvent.setup(); @@ -112,7 +118,9 @@ describe("Empty state", () => { "should send the message to the socket only if the runtime is active", async () => { useWsClientMock.mockImplementation(() => ({ - send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false + send: sendMock, + status: WsClientProviderStatus.ACTIVE, + isLoadingMessages: false, })); const user = userEvent.setup(); const { rerender } = renderWithProviders(, { @@ -121,7 +129,6 @@ describe("Empty state", () => { }, }); - const suggestions = screen.getByTestId("suggestions"); const displayedSuggestions = within(suggestions).getAllByRole("button"); @@ -129,7 +136,9 @@ describe("Empty state", () => { expect(sendMock).not.toHaveBeenCalled(); useWsClientMock.mockImplementation(() => ({ - send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false + send: sendMock, + status: WsClientProviderStatus.ACTIVE, + isLoadingMessages: false, })); rerender(); @@ -330,10 +339,16 @@ describe.skip("ChatInterface", () => { rerender(); // Verify only one button is shown - const pushToPrButton = screen.getByRole("button", { name: "Push changes to PR" }); + const pushToPrButton = screen.getByRole("button", { + name: "Push changes to PR", + }); expect(pushToPrButton).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Push to Branch" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Push & Create PR" }), + ).not.toBeInTheDocument(); }); it("should render feedback actions if there are more than 3 messages", () => { @@ -379,4 +394,4 @@ describe.skip("ChatInterface", () => { ); it.todo("should render the actions once more after new messages are added"); }); -}); \ No newline at end of file +});