|
|
@@ -2,72 +2,29 @@ import { useDisclosure } from "@nextui-org/react";
|
|
|
import React from "react";
|
|
|
import {
|
|
|
Outlet,
|
|
|
- useFetcher,
|
|
|
useLoaderData,
|
|
|
json,
|
|
|
ClientActionFunctionArgs,
|
|
|
- useRouteLoaderData,
|
|
|
} from "@remix-run/react";
|
|
|
-import { useDispatch, useSelector } from "react-redux";
|
|
|
-import WebSocket from "ws";
|
|
|
-import toast from "react-hot-toast";
|
|
|
-import posthog from "posthog-js";
|
|
|
+import { useDispatch } from "react-redux";
|
|
|
import { getSettings } from "#/services/settings";
|
|
|
import Security from "../components/modals/security/Security";
|
|
|
import { Controls } from "#/components/controls";
|
|
|
-import store, { RootState } from "#/store";
|
|
|
+import store from "#/store";
|
|
|
import { Container } from "#/components/container";
|
|
|
-import ActionType from "#/types/ActionType";
|
|
|
-import { handleAssistantMessage } from "#/services/actions";
|
|
|
-import {
|
|
|
- addErrorMessage,
|
|
|
- addUserMessage,
|
|
|
- clearMessages,
|
|
|
-} from "#/state/chatSlice";
|
|
|
-import { useSocket } from "#/context/socket";
|
|
|
-import {
|
|
|
- getGitHubTokenCommand,
|
|
|
- getCloneRepoCommand,
|
|
|
-} from "#/services/terminalService";
|
|
|
+import { clearMessages } from "#/state/chatSlice";
|
|
|
import { clearTerminal } from "#/state/commandSlice";
|
|
|
import { useEffectOnce } from "#/utils/use-effect-once";
|
|
|
import CodeIcon from "#/icons/code.svg?react";
|
|
|
import GlobeIcon from "#/icons/globe.svg?react";
|
|
|
import ListIcon from "#/icons/list-type-number.svg?react";
|
|
|
-import { createChatMessage } from "#/services/chatService";
|
|
|
-import {
|
|
|
- clearFiles,
|
|
|
- clearInitialQuery,
|
|
|
- clearSelectedRepository,
|
|
|
- setImportedProjectZip,
|
|
|
-} from "#/state/initial-query-slice";
|
|
|
+import { clearInitialQuery } from "#/state/initial-query-slice";
|
|
|
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
|
|
|
-import OpenHands from "#/api/open-hands";
|
|
|
-import AgentState from "#/types/AgentState";
|
|
|
-import { base64ToBlob } from "#/utils/base64-to-blob";
|
|
|
-import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
|
|
import { clearJupyter } from "#/state/jupyterSlice";
|
|
|
import { FilesProvider } from "#/context/files";
|
|
|
-import { ErrorObservation } from "#/types/core/observations";
|
|
|
import { ChatInterface } from "#/components/chat-interface";
|
|
|
-
|
|
|
-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";
|
|
|
-
|
|
|
-const isAgentStateChange = (
|
|
|
- data: object,
|
|
|
-): data is { extras: { agent_state: AgentState } } =>
|
|
|
- "extras" in data &&
|
|
|
- data.extras instanceof Object &&
|
|
|
- "agent_state" in data.extras;
|
|
|
+import { WsClientProvider } from "#/context/ws-client-provider";
|
|
|
+import { EventHandler } from "#/components/event-handler";
|
|
|
|
|
|
export const clientLoader = async () => {
|
|
|
const ghToken = localStorage.getItem("ghToken");
|
|
|
@@ -117,179 +74,26 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
|
|
|
|
|
function App() {
|
|
|
const dispatch = useDispatch();
|
|
|
- const { files, importedProjectZip } = useSelector(
|
|
|
- (state: RootState) => state.initalQuery,
|
|
|
- );
|
|
|
- const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
|
|
|
- const { settings, token, ghToken, repo, q, lastCommit } =
|
|
|
+ const { settings, token, ghToken, lastCommit } =
|
|
|
useLoaderData<typeof clientLoader>();
|
|
|
- const fetcher = useFetcher();
|
|
|
- const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
|
|
|
|
|
const secrets = React.useMemo(
|
|
|
() => [ghToken, token].filter((secret) => secret !== null),
|
|
|
[ghToken, token],
|
|
|
);
|
|
|
|
|
|
- // To avoid re-rendering the component when the user object changes, we memoize the user ID.
|
|
|
- // We use this to ensure the github token is valid before exporting it to the terminal.
|
|
|
- const userId = React.useMemo(() => {
|
|
|
- if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
|
|
|
- return null;
|
|
|
- }, [data?.user]);
|
|
|
-
|
|
|
const Terminal = React.useMemo(
|
|
|
() => React.lazy(() => import("../components/terminal/Terminal")),
|
|
|
[],
|
|
|
);
|
|
|
|
|
|
- const addIntialQueryToChat = (
|
|
|
- query: string,
|
|
|
- base64Files: string[],
|
|
|
- timestamp = new Date().toISOString(),
|
|
|
- ) => {
|
|
|
- dispatch(
|
|
|
- addUserMessage({
|
|
|
- content: query,
|
|
|
- imageUrls: base64Files,
|
|
|
- timestamp,
|
|
|
- }),
|
|
|
- );
|
|
|
- };
|
|
|
-
|
|
|
- const sendInitialQuery = (query: string, base64Files: string[]) => {
|
|
|
- const timestamp = new Date().toISOString();
|
|
|
- send(createChatMessage(query, base64Files, timestamp));
|
|
|
-
|
|
|
- const userSettings = getSettings();
|
|
|
- if (userSettings.LLM_API_KEY) {
|
|
|
- posthog.capture("user_activated");
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const handleOpen = React.useCallback(() => {
|
|
|
- const initEvent = {
|
|
|
- action: ActionType.INIT,
|
|
|
- args: settings,
|
|
|
- };
|
|
|
- send(JSON.stringify(initEvent));
|
|
|
-
|
|
|
- // display query in UI, but don't send it to the server
|
|
|
- if (q) addIntialQueryToChat(q, files);
|
|
|
- }, [settings]);
|
|
|
-
|
|
|
- const handleMessage = React.useCallback(
|
|
|
- (message: MessageEvent<WebSocket.Data>) => {
|
|
|
- // set token received from the server
|
|
|
- const parsed = JSON.parse(message.data.toString());
|
|
|
- if ("token" in parsed) {
|
|
|
- fetcher.submit({ token: parsed.token }, { method: "post" });
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (isServerError(parsed)) {
|
|
|
- if (parsed.error_code === 401) {
|
|
|
- toast.error("Session expired.");
|
|
|
- fetcher.submit({}, { method: "POST", action: "/end-session" });
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (typeof parsed.error === "string") {
|
|
|
- toast.error(parsed.error);
|
|
|
- } else {
|
|
|
- toast.error(parsed.message);
|
|
|
- }
|
|
|
-
|
|
|
- return;
|
|
|
- }
|
|
|
- if (isErrorObservation(parsed)) {
|
|
|
- dispatch(
|
|
|
- addErrorMessage({
|
|
|
- id: parsed.extras?.error_id,
|
|
|
- message: parsed.message,
|
|
|
- }),
|
|
|
- );
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- handleAssistantMessage(message.data.toString());
|
|
|
-
|
|
|
- // handle first time connection
|
|
|
- if (
|
|
|
- isAgentStateChange(parsed) &&
|
|
|
- parsed.extras.agent_state === AgentState.INIT
|
|
|
- ) {
|
|
|
- setRuntimeIsInitialized();
|
|
|
-
|
|
|
- // handle new session
|
|
|
- if (!token) {
|
|
|
- let additionalInfo = "";
|
|
|
- if (ghToken && repo) {
|
|
|
- send(getCloneRepoCommand(ghToken, repo));
|
|
|
- additionalInfo = `Repository ${repo} 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 (q) {
|
|
|
- if (additionalInfo) {
|
|
|
- sendInitialQuery(`${q}\n\n[${additionalInfo}]`, files);
|
|
|
- } else {
|
|
|
- sendInitialQuery(q, files);
|
|
|
- }
|
|
|
- dispatch(clearFiles()); // reset selected files
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
- [token, ghToken, repo, q, files],
|
|
|
- );
|
|
|
-
|
|
|
- const startSocketConnection = React.useCallback(() => {
|
|
|
- start({
|
|
|
- token,
|
|
|
- onOpen: handleOpen,
|
|
|
- onMessage: handleMessage,
|
|
|
- });
|
|
|
- }, [token, handleOpen, handleMessage]);
|
|
|
-
|
|
|
useEffectOnce(() => {
|
|
|
- // clear and restart the socket connection
|
|
|
dispatch(clearMessages());
|
|
|
dispatch(clearTerminal());
|
|
|
dispatch(clearJupyter());
|
|
|
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
|
|
|
- startSocketConnection();
|
|
|
});
|
|
|
|
|
|
- React.useEffect(() => {
|
|
|
- if (runtimeActive && userId && ghToken) {
|
|
|
- // Export if the user valid, this could happen mid-session so it is handled here
|
|
|
- send(getGitHubTokenCommand(ghToken));
|
|
|
- }
|
|
|
- }, [userId, ghToken, runtimeActive]);
|
|
|
-
|
|
|
- React.useEffect(() => {
|
|
|
- (async () => {
|
|
|
- if (runtimeActive && importedProjectZip) {
|
|
|
- // upload files action
|
|
|
- try {
|
|
|
- const blob = base64ToBlob(importedProjectZip);
|
|
|
- const file = new File([blob], "imported-project.zip", {
|
|
|
- type: blob.type,
|
|
|
- });
|
|
|
- await OpenHands.uploadFiles([file]);
|
|
|
- dispatch(setImportedProjectZip(null));
|
|
|
- } catch (error) {
|
|
|
- toast.error("Failed to upload project files.");
|
|
|
- }
|
|
|
- }
|
|
|
- })();
|
|
|
- }, [runtimeActive, importedProjectZip]);
|
|
|
-
|
|
|
const {
|
|
|
isOpen: securityModalIsOpen,
|
|
|
onOpen: onSecurityModalOpen,
|
|
|
@@ -297,53 +101,62 @@ function App() {
|
|
|
} = useDisclosure();
|
|
|
|
|
|
return (
|
|
|
- <div className="flex flex-col h-full gap-3">
|
|
|
- <div className="flex h-full overflow-auto gap-3">
|
|
|
- <Container className="w-[390px] max-h-full relative">
|
|
|
- <ChatInterface />
|
|
|
- </Container>
|
|
|
-
|
|
|
- <div className="flex flex-col grow gap-3">
|
|
|
- <Container
|
|
|
- className="h-2/3"
|
|
|
- labels={[
|
|
|
- { label: "Workspace", to: "", icon: <CodeIcon /> },
|
|
|
- { label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
|
|
- {
|
|
|
- label: "Browser",
|
|
|
- to: "browser",
|
|
|
- icon: <GlobeIcon />,
|
|
|
- isBeta: true,
|
|
|
- },
|
|
|
- ]}
|
|
|
- >
|
|
|
- <FilesProvider>
|
|
|
- <Outlet />
|
|
|
- </FilesProvider>
|
|
|
- </Container>
|
|
|
- {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
|
|
- * that it loads only in the client-side. */}
|
|
|
- <Container className="h-1/3 overflow-scroll" label="Terminal">
|
|
|
- <React.Suspense fallback={<div className="h-full" />}>
|
|
|
- <Terminal secrets={secrets} />
|
|
|
- </React.Suspense>
|
|
|
- </Container>
|
|
|
+ <WsClientProvider
|
|
|
+ enabled
|
|
|
+ token={token}
|
|
|
+ ghToken={ghToken}
|
|
|
+ settings={settings}
|
|
|
+ >
|
|
|
+ <EventHandler>
|
|
|
+ <div className="flex flex-col h-full gap-3">
|
|
|
+ <div className="flex h-full overflow-auto gap-3">
|
|
|
+ <Container className="w-[390px] max-h-full relative">
|
|
|
+ <ChatInterface />
|
|
|
+ </Container>
|
|
|
+
|
|
|
+ <div className="flex flex-col grow gap-3">
|
|
|
+ <Container
|
|
|
+ className="h-2/3"
|
|
|
+ labels={[
|
|
|
+ { label: "Workspace", to: "", icon: <CodeIcon /> },
|
|
|
+ { label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
|
|
+ {
|
|
|
+ label: "Browser",
|
|
|
+ to: "browser",
|
|
|
+ icon: <GlobeIcon />,
|
|
|
+ isBeta: true,
|
|
|
+ },
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <FilesProvider>
|
|
|
+ <Outlet />
|
|
|
+ </FilesProvider>
|
|
|
+ </Container>
|
|
|
+ {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
|
|
+ * that it loads only in the client-side. */}
|
|
|
+ <Container className="h-1/3 overflow-scroll" label="Terminal">
|
|
|
+ <React.Suspense fallback={<div className="h-full" />}>
|
|
|
+ <Terminal secrets={secrets} />
|
|
|
+ </React.Suspense>
|
|
|
+ </Container>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="h-[60px]">
|
|
|
+ <Controls
|
|
|
+ setSecurityOpen={onSecurityModalOpen}
|
|
|
+ showSecurityLock={!!settings.SECURITY_ANALYZER}
|
|
|
+ lastCommitData={lastCommit}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Security
|
|
|
+ isOpen={securityModalIsOpen}
|
|
|
+ onOpenChange={onSecurityModalOpenChange}
|
|
|
+ securityAnalyzer={settings.SECURITY_ANALYZER}
|
|
|
+ />
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="h-[60px]">
|
|
|
- <Controls
|
|
|
- setSecurityOpen={onSecurityModalOpen}
|
|
|
- showSecurityLock={!!settings.SECURITY_ANALYZER}
|
|
|
- lastCommitData={lastCommit}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <Security
|
|
|
- isOpen={securityModalIsOpen}
|
|
|
- onOpenChange={onSecurityModalOpenChange}
|
|
|
- securityAnalyzer={settings.SECURITY_ANALYZER}
|
|
|
- />
|
|
|
- </div>
|
|
|
+ </EventHandler>
|
|
|
+ </WsClientProvider>
|
|
|
);
|
|
|
}
|
|
|
|