diff --git a/README.md b/README.md index da882d79bc0d..23b7c57d5a9e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ docker run -it --pull=always \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ + -e LOG_ALL_EVENTS=true \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ docker.all-hands.dev/all-hands-ai/openhands:0.13 diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index d3fc1c2947c7..adba43e47468 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -49,6 +49,7 @@ docker run -it \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ -e LLM_MODEL=$LLM_MODEL \ + -e LOG_ALL_EVENTS=true \ -v $WORKSPACE_BASE:/opt/workspace_base \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index c01c3f98997b..7a7f2c352b6d 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -17,6 +17,7 @@ docker run -it --rm --pull=always \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ + -e LOG_ALL_EVENTS=true \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ docker.all-hands.dev/all-hands-ai/openhands:0.13 diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 71116e96515a..fc4c03e3f68c 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -16,14 +16,14 @@ describe("Empty state", () => { send: vi.fn(), })); - const { useSocket: useSocketMock } = vi.hoisted(() => ({ - useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })), + const { useWsClient: useWsClientMock } = vi.hoisted(() => ({ + useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })), })); beforeAll(() => { vi.mock("#/context/socket", async (importActual) => ({ - ...(await importActual()), - useSocket: useSocketMock, + ...(await importActual()), + useWsClient: useWsClientMock, })); }); @@ -77,7 +77,7 @@ describe("Empty state", () => { "should load the a user message to the input when selecting", async () => { // this is to test that the message is in the UI before the socket is called - useSocketMock.mockImplementation(() => ({ + useWsClientMock.mockImplementation(() => ({ send: sendMock, runtimeActive: false, // mock an inactive runtime setup })); @@ -106,7 +106,7 @@ describe("Empty state", () => { it.fails( "should send the message to the socket only if the runtime is active", async () => { - useSocketMock.mockImplementation(() => ({ + useWsClientMock.mockImplementation(() => ({ send: sendMock, runtimeActive: false, // mock an inactive runtime setup })); @@ -123,7 +123,7 @@ describe("Empty state", () => { await user.click(displayedSuggestions[0]); expect(sendMock).not.toHaveBeenCalled(); - useSocketMock.mockImplementation(() => ({ + useWsClientMock.mockImplementation(() => ({ send: sendMock, runtimeActive: true, // mock an active runtime setup })); diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index aec9633d3a5e..7a5d6e9c5460 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import { render } from "@testing-library/react"; import { afterEach } from "node:test"; import { useTerminal } from "#/hooks/useTerminal"; -import { SocketProvider } from "#/context/socket"; import { Command } from "#/state/commandSlice"; +import { WsClientProvider } from "#/context/ws-client-provider"; +import { ReactNode } from "react"; interface TestTerminalComponentProps { commands: Command[]; @@ -18,6 +19,17 @@ function TestTerminalComponent({ return
; } +interface WrapperProps { + children: ReactNode; +} + + +function Wrapper({children}: WrapperProps) { + return ( + {children} + ) +} + describe("useTerminal", () => { const mockTerminal = vi.hoisted(() => ({ loadAddon: vi.fn(), @@ -50,7 +62,7 @@ describe("useTerminal", () => { it("should render", () => { render(, { - wrapper: SocketProvider, + wrapper: Wrapper, }); }); @@ -61,7 +73,7 @@ describe("useTerminal", () => { ]; render(, { - wrapper: SocketProvider, + wrapper: Wrapper, }); expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello"); @@ -85,7 +97,7 @@ describe("useTerminal", () => { secrets={[secret, anotherSecret]} />, { - wrapper: SocketProvider, + wrapper: Wrapper, }, ); diff --git a/frontend/__tests__/utils/extractModelAndProvider.test.ts b/frontend/__tests__/utils/extractModelAndProvider.test.ts index c1ec4ee838ec..2ec00e50abdc 100644 --- a/frontend/__tests__/utils/extractModelAndProvider.test.ts +++ b/frontend/__tests__/utils/extractModelAndProvider.test.ts @@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => { separator: "/", }); - expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({ + expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({ provider: "anthropic", - model: "claude-3-5-sonnet-20241022", + model: "claude-3-5-sonnet-20240620", separator: "/", }); diff --git a/frontend/__tests__/utils/organizeModelsAndProviders.test.ts b/frontend/__tests__/utils/organizeModelsAndProviders.test.ts index aa3c84707432..9d67e8777fab 100644 --- a/frontend/__tests__/utils/organizeModelsAndProviders.test.ts +++ b/frontend/__tests__/utils/organizeModelsAndProviders.test.ts @@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => { "gpt-4o", "together-ai-21.1b-41b", "gpt-4o-mini", - "claude-3-5-sonnet-20241022", + "anthropic/claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "claude-2", "claude-2.1", diff --git a/frontend/src/components/AgentControlBar.tsx b/frontend/src/components/AgentControlBar.tsx index 7dfc0e3817e1..f6bcea809098 100644 --- a/frontend/src/components/AgentControlBar.tsx +++ b/frontend/src/components/AgentControlBar.tsx @@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play"; import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { RootState } from "#/store"; import AgentState from "#/types/AgentState"; -import { useSocket } from "#/context/socket"; +import { useWsClient } from "#/context/ws-client-provider"; const IgnoreTaskStateMap: Record = { [AgentState.PAUSED]: [ @@ -72,7 +72,7 @@ function ActionButton({ } function AgentControlBar() { - const { send } = useSocket(); + const { send } = useWsClient(); const { curAgentState } = useSelector((state: RootState) => state.agent); const handleAction = (action: AgentState) => { diff --git a/frontend/src/components/attach-image-label.tsx b/frontend/src/components/attach-image-label.tsx index f3b9c7ebc13a..9b3f1f413cf1 100644 --- a/frontend/src/components/attach-image-label.tsx +++ b/frontend/src/components/attach-image-label.tsx @@ -1,4 +1,4 @@ -import Clip from "#/assets/clip.svg?react"; +import Clip from "#/icons/clip.svg?react"; export function AttachImageLabel() { return ( diff --git a/frontend/src/components/chat-input.tsx b/frontend/src/components/chat-input.tsx index 6e223b5567c6..2b3c69b21c59 100644 --- a/frontend/src/components/chat-input.tsx +++ b/frontend/src/components/chat-input.tsx @@ -1,6 +1,6 @@ import React from "react"; import TextareaAutosize from "react-textarea-autosize"; -import ArrowSendIcon from "#/assets/arrow-send.svg?react"; +import ArrowSendIcon from "#/icons/arrow-send.svg?react"; import { cn } from "#/utils/utils"; interface ChatInputProps { diff --git a/frontend/src/components/chat-interface.tsx b/frontend/src/components/chat-interface.tsx index 717ca2af3c0b..626166f46260 100644 --- a/frontend/src/components/chat-interface.tsx +++ b/frontend/src/components/chat-interface.tsx @@ -1,7 +1,6 @@ import { useDispatch, useSelector } from "react-redux"; import React from "react"; import posthog from "posthog-js"; -import { useSocket } from "#/context/socket"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { ChatMessage } from "./chat-message"; import { FeedbackActions } from "./feedback-actions"; @@ -21,14 +20,15 @@ import { ContinueButton } from "./continue-button"; import { ScrollToBottomButton } from "./scroll-to-bottom-button"; import { Suggestions } from "./suggestions"; import { SUGGESTIONS } from "#/utils/suggestions"; -import BuildIt from "#/assets/build-it.svg?react"; +import BuildIt from "#/icons/build-it.svg?react"; +import { useWsClient } from "#/context/ws-client-provider"; const isErrorMessage = ( message: Message | ErrorMessage, ): message is ErrorMessage => "error" in message; export function ChatInterface() { - const { send } = useSocket(); + const { send } = useWsClient(); const dispatch = useDispatch(); const scrollRef = React.useRef(null); const { scrollDomToBottom, onChatBodyScroll, hitBottom } = diff --git a/frontend/src/components/chat/ConfirmationButtons.tsx b/frontend/src/components/chat/ConfirmationButtons.tsx index fb1f64f14a4f..c06dd76fe03d 100644 --- a/frontend/src/components/chat/ConfirmationButtons.tsx +++ b/frontend/src/components/chat/ConfirmationButtons.tsx @@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject"; import { I18nKey } from "#/i18n/declaration"; import AgentState from "#/types/AgentState"; import { generateAgentStateChangeEvent } from "#/services/agentStateService"; -import { useSocket } from "#/context/socket"; +import { useWsClient } from "#/context/ws-client-provider"; interface ActionTooltipProps { type: "confirm" | "reject"; @@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) { function ConfirmationButtons() { const { t } = useTranslation(); - const { send } = useSocket(); + const { send } = useWsClient(); const handleStateChange = (state: AgentState) => { const event = generateAgentStateChangeEvent(state); diff --git a/frontend/src/components/event-handler.tsx b/frontend/src/components/event-handler.tsx new file mode 100644 index 000000000000..75eef4116476 --- /dev/null +++ b/frontend/src/components/event-handler.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { + useFetcher, + useLoaderData, + useRouteLoaderData, +} from "@remix-run/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/chatSlice"; +import { handleAssistantMessage } from "#/services/actions"; +import { + getCloneRepoCommand, + getGitHubTokenCommand, +} from "#/services/terminalService"; +import { + clearFiles, + clearSelectedRepository, + setImportedProjectZip, +} from "#/state/initial-query-slice"; +import { clientLoader as appClientLoader } from "#/routes/_oh.app"; +import store, { RootState } from "#/store"; +import { createChatMessage } from "#/services/chatService"; +import { clientLoader as rootClientLoader } from "#/routes/_oh"; +import { isGitHubErrorReponse } from "#/api/github"; +import OpenHands from "#/api/open-hands"; +import { base64ToBlob } from "#/utils/base64-to-blob"; +import { setCurrentAgentState } from "#/state/agentSlice"; +import AgentState from "#/types/AgentState"; +import { getSettings } from "#/services/settings"; + +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 { events, status, send } = useWsClient(); + const statusRef = React.useRef(null); + const runtimeActive = status === WsClientProviderStatus.ACTIVE; + const fetcher = useFetcher(); + const dispatch = useDispatch(); + const { files, importedProjectZip } = useSelector( + (state: RootState) => state.initalQuery, + ); + const { ghToken, repo } = useLoaderData(); + const initialQueryRef = React.useRef( + store.getState().initalQuery.initialQuery, + ); + + const sendInitialQuery = (query: string, base64Files: string[]) => { + const timestamp = new Date().toISOString(); + send(createChatMessage(query, base64Files, timestamp)); + }; + const data = useRouteLoaderData("routes/_oh"); + const userId = React.useMemo(() => { + if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id; + return null; + }, [data?.user]); + const userSettings = getSettings(); + + React.useEffect(() => { + if (!events.length) { + return; + } + const event = events[events.length - 1]; + if (event.token) { + fetcher.submit({ token: event.token as string }, { method: "post" }); + return; + } + + if (isServerError(event)) { + if (event.error_code === 401) { + toast.error("Session expired."); + fetcher.submit({}, { method: "POST", action: "/end-session" }); + return; + } + + if (typeof event.error === "string") { + toast.error(event.error); + } else { + toast.error(event.message); + } + return; + } + + if (isErrorObservation(event)) { + dispatch( + addErrorMessage({ + id: event.extras?.error_id, + message: event.message, + }), + ); + return; + } + handleAssistantMessage(event); + }, [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; + const initialQuery = initialQueryRef.current; + + if (status === WsClientProviderStatus.ACTIVE) { + 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 (initialQuery) { + if (additionalInfo) { + sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files); + } else { + sendInitialQuery(initialQuery, files); + } + dispatch(clearFiles()); // reset selected files + initialQueryRef.current = null; + } + } + + 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 && 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]); + + React.useEffect(() => { + if (userSettings.LLM_API_KEY) { + posthog.capture("user_activated"); + } + }, [userSettings.LLM_API_KEY]); + + return children; +} diff --git a/frontend/src/components/image-preview.tsx b/frontend/src/components/image-preview.tsx index 0270cc8e0a6e..a8be0b24d624 100644 --- a/frontend/src/components/image-preview.tsx +++ b/frontend/src/components/image-preview.tsx @@ -1,4 +1,4 @@ -import CloseIcon from "#/assets/close.svg?react"; +import CloseIcon from "#/icons/close.svg?react"; import { cn } from "#/utils/utils"; interface ImagePreviewProps { diff --git a/frontend/src/components/modals/LoadingProject.tsx b/frontend/src/components/modals/LoadingProject.tsx index 1630a66983d6..e63af0fb9835 100644 --- a/frontend/src/components/modals/LoadingProject.tsx +++ b/frontend/src/components/modals/LoadingProject.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react"; +import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react"; import { cn } from "#/utils/utils"; import ModalBody from "./ModalBody"; import { I18nKey } from "#/i18n/declaration"; diff --git a/frontend/src/components/project-menu/ProjectMenuCard.tsx b/frontend/src/components/project-menu/ProjectMenuCard.tsx index 847ce683e48b..1a32c2f802d1 100644 --- a/frontend/src/components/project-menu/ProjectMenuCard.tsx +++ b/frontend/src/components/project-menu/ProjectMenuCard.tsx @@ -2,17 +2,17 @@ import React from "react"; import { useDispatch } from "react-redux"; import toast from "react-hot-toast"; import posthog from "posthog-js"; -import EllipsisH from "#/assets/ellipsis-h.svg?react"; +import EllipsisH from "#/icons/ellipsis-h.svg?react"; import { ModalBackdrop } from "../modals/modal-backdrop"; import { ConnectToGitHubModal } from "../modals/connect-to-github-modal"; import { addUserMessage } from "#/state/chatSlice"; -import { useSocket } from "#/context/socket"; import { createChatMessage } from "#/services/chatService"; import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu"; import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder"; import { ProjectMenuDetails } from "./project-menu-details"; import { downloadWorkspace } from "#/utils/download-workspace"; import { LoadingSpinner } from "../modals/LoadingProject"; +import { useWsClient } from "#/context/ws-client-provider"; interface ProjectMenuCardProps { isConnectedToGitHub: boolean; @@ -27,7 +27,7 @@ export function ProjectMenuCard({ isConnectedToGitHub, githubData, }: ProjectMenuCardProps) { - const { send } = useSocket(); + const { send } = useWsClient(); const dispatch = useDispatch(); const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false); diff --git a/frontend/src/components/project-menu/project-menu-details-placeholder.tsx b/frontend/src/components/project-menu/project-menu-details-placeholder.tsx index 153f4e8093c9..f9556d8c0a39 100644 --- a/frontend/src/components/project-menu/project-menu-details-placeholder.tsx +++ b/frontend/src/components/project-menu/project-menu-details-placeholder.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { cn } from "#/utils/utils"; -import CloudConnection from "#/assets/cloud-connection.svg?react"; +import CloudConnection from "#/icons/cloud-connection.svg?react"; import { I18nKey } from "#/i18n/declaration"; interface ProjectMenuDetailsPlaceholderProps { diff --git a/frontend/src/components/project-menu/project-menu-details.tsx b/frontend/src/components/project-menu/project-menu-details.tsx index 536f04d71d3b..6b5382a43689 100644 --- a/frontend/src/components/project-menu/project-menu-details.tsx +++ b/frontend/src/components/project-menu/project-menu-details.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import ExternalLinkIcon from "#/assets/external-link.svg?react"; +import ExternalLinkIcon from "#/icons/external-link.svg?react"; import { formatTimeDelta } from "#/utils/format-time-delta"; import { I18nKey } from "#/i18n/declaration"; diff --git a/frontend/src/components/scroll-to-bottom-button.tsx b/frontend/src/components/scroll-to-bottom-button.tsx index 5c25e7be0130..49c5542d7b7a 100644 --- a/frontend/src/components/scroll-to-bottom-button.tsx +++ b/frontend/src/components/scroll-to-bottom-button.tsx @@ -1,4 +1,4 @@ -import ArrowSendIcon from "#/assets/arrow-send.svg?react"; +import ArrowSendIcon from "#/icons/arrow-send.svg?react"; interface ScrollToBottomButtonProps { onClick: () => void; diff --git a/frontend/src/components/suggestion-bubble.tsx b/frontend/src/components/suggestion-bubble.tsx index 3673995e493c..00fc1b7d0d6b 100644 --- a/frontend/src/components/suggestion-bubble.tsx +++ b/frontend/src/components/suggestion-bubble.tsx @@ -1,5 +1,5 @@ -import Lightbulb from "#/assets/lightbulb.svg?react"; -import Refresh from "#/assets/refresh.svg?react"; +import Lightbulb from "#/icons/lightbulb.svg?react"; +import Refresh from "#/icons/refresh.svg?react"; interface SuggestionBubbleProps { suggestion: string; diff --git a/frontend/src/components/upload-image-input.tsx b/frontend/src/components/upload-image-input.tsx index e97d1f427f34..a9003b394a90 100644 --- a/frontend/src/components/upload-image-input.tsx +++ b/frontend/src/components/upload-image-input.tsx @@ -1,4 +1,4 @@ -import Clip from "#/assets/clip.svg?react"; +import Clip from "#/icons/clip.svg?react"; interface UploadImageInputProps { onUpload: (files: File[]) => void; diff --git a/frontend/src/components/user-avatar.tsx b/frontend/src/components/user-avatar.tsx index 88f09caf93b1..a7ff9209d5ea 100644 --- a/frontend/src/components/user-avatar.tsx +++ b/frontend/src/components/user-avatar.tsx @@ -1,5 +1,5 @@ import { LoadingSpinner } from "./modals/LoadingProject"; -import DefaultUserAvatar from "#/assets/default-user.svg?react"; +import DefaultUserAvatar from "#/icons/default-user.svg?react"; import { cn } from "#/utils/utils"; interface UserAvatarProps { diff --git a/frontend/src/context/socket.tsx b/frontend/src/context/socket.tsx deleted file mode 100644 index 7bf1ab1d5709..000000000000 --- a/frontend/src/context/socket.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from "react"; -import { Data } from "ws"; -import posthog from "posthog-js"; -import EventLogger from "#/utils/event-logger"; - -interface WebSocketClientOptions { - token: string | null; - onOpen?: (event: Event) => void; - onMessage?: (event: MessageEvent) => void; - onError?: (event: Event) => void; - onClose?: (event: Event) => void; -} - -interface WebSocketContextType { - send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void; - start: (options?: WebSocketClientOptions) => void; - stop: () => void; - setRuntimeIsInitialized: () => void; - runtimeActive: boolean; - isConnected: boolean; - events: Record[]; -} - -const SocketContext = React.createContext( - undefined, -); - -interface SocketProviderProps { - children: React.ReactNode; -} - -function SocketProvider({ children }: SocketProviderProps) { - const wsRef = React.useRef(null); - const [isConnected, setIsConnected] = React.useState(false); - const [runtimeActive, setRuntimeActive] = React.useState(false); - const [events, setEvents] = React.useState[]>([]); - - const setRuntimeIsInitialized = () => { - setRuntimeActive(true); - }; - - const start = React.useCallback((options?: WebSocketClientOptions): void => { - if (wsRef.current) { - EventLogger.warning( - "WebSocket connection is already established, but a new one is starting anyways.", - ); - } - - const baseUrl = - import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host; - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated - const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB"; - - const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [ - "openhands", - sessionToken, - ghToken, - ]); - - ws.addEventListener("open", (event) => { - posthog.capture("socket_opened"); - setIsConnected(true); - options?.onOpen?.(event); - }); - - ws.addEventListener("message", (event) => { - EventLogger.message(event); - - setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]); - options?.onMessage?.(event); - }); - - ws.addEventListener("error", (event) => { - posthog.capture("socket_error"); - EventLogger.event(event, "SOCKET ERROR"); - options?.onError?.(event); - }); - - ws.addEventListener("close", (event) => { - posthog.capture("socket_closed"); - EventLogger.event(event, "SOCKET CLOSE"); - - setIsConnected(false); - setRuntimeActive(false); - wsRef.current = null; - options?.onClose?.(event); - }); - - wsRef.current = ws; - }, []); - - const stop = React.useCallback((): void => { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }, []); - - const send = React.useCallback( - (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { - if (!wsRef.current) { - EventLogger.error("WebSocket is not connected."); - return; - } - setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]); - wsRef.current.send(data); - }, - [], - ); - - const value = React.useMemo( - () => ({ - send, - start, - stop, - setRuntimeIsInitialized, - runtimeActive, - isConnected, - events, - }), - [ - send, - start, - stop, - setRuntimeIsInitialized, - runtimeActive, - isConnected, - events, - ], - ); - - return ( - {children} - ); -} - -function useSocket() { - const context = React.useContext(SocketContext); - if (context === undefined) { - throw new Error("useSocket must be used within a SocketProvider"); - } - return context; -} - -export { SocketProvider, useSocket }; diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx new file mode 100644 index 000000000000..bfecefadd00a --- /dev/null +++ b/frontend/src/context/ws-client-provider.tsx @@ -0,0 +1,175 @@ +import posthog from "posthog-js"; +import React from "react"; +import { Settings } from "#/services/settings"; +import ActionType from "#/types/ActionType"; +import EventLogger from "#/utils/event-logger"; +import AgentState from "#/types/AgentState"; + +export enum WsClientProviderStatus { + STOPPED, + OPENING, + ACTIVE, + ERROR, +} + +interface UseWsClient { + status: WsClientProviderStatus; + events: Record[]; + send: (event: Record) => void; +} + +const WsClientContext = React.createContext({ + status: WsClientProviderStatus.STOPPED, + events: [], + send: () => { + throw new Error("not connected"); + }, +}); + +interface WsClientProviderProps { + enabled: boolean; + token: string | null; + ghToken: string | null; + settings: Settings | null; +} + +export function WsClientProvider({ + enabled, + token, + ghToken, + settings, + children, +}: React.PropsWithChildren) { + const wsRef = React.useRef(null); + const tokenRef = React.useRef(token); + const ghTokenRef = React.useRef(ghToken); + const closeRef = React.useRef | null>(null); + const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED); + const [events, setEvents] = React.useState[]>([]); + + function send(event: Record) { + if (!wsRef.current) { + EventLogger.error("WebSocket is not connected."); + return; + } + wsRef.current.send(JSON.stringify(event)); + } + + function handleOpen() { + setStatus(WsClientProviderStatus.OPENING); + const initEvent = { + action: ActionType.INIT, + args: settings, + }; + send(initEvent); + } + + function handleMessage(messageEvent: MessageEvent) { + const event = JSON.parse(messageEvent.data); + setEvents((prevEvents) => [...prevEvents, event]); + if (event.extras?.agent_state === AgentState.INIT) { + setStatus(WsClientProviderStatus.ACTIVE); + } + if ( + status !== WsClientProviderStatus.ACTIVE && + event?.observation === "error" + ) { + setStatus(WsClientProviderStatus.ERROR); + } + } + + function handleClose() { + setStatus(WsClientProviderStatus.STOPPED); + setEvents([]); + wsRef.current = null; + } + + function handleError(event: Event) { + posthog.capture("socket_error"); + EventLogger.event(event, "SOCKET ERROR"); + setStatus(WsClientProviderStatus.ERROR); + } + + // Connect websocket + React.useEffect(() => { + let ws = wsRef.current; + + // If disabled close any existing websockets... + if (!enabled) { + if (ws) { + ws.close(); + } + wsRef.current = null; + return () => {}; + } + + // If there is no websocket or the tokens have changed or the current websocket is closed, + // create a new one + if ( + !ws || + (tokenRef.current && token !== tokenRef.current) || + ghToken !== ghTokenRef.current || + ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING + ) { + ws?.close(); + const baseUrl = + import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [ + "openhands", + token || "NO_JWT", + ghToken || "NO_GITHUB", + ]); + } + ws.addEventListener("open", handleOpen); + ws.addEventListener("message", handleMessage); + ws.addEventListener("error", handleError); + ws.addEventListener("close", handleClose); + wsRef.current = ws; + tokenRef.current = token; + ghTokenRef.current = ghToken; + + return () => { + ws.removeEventListener("open", handleOpen); + ws.removeEventListener("message", handleMessage); + ws.removeEventListener("error", handleError); + ws.removeEventListener("close", handleClose); + }; + }, [enabled, token, ghToken]); + + // Strict mode mounts and unmounts each component twice, so we have to wait in the destructor + // before actually closing the socket and cancel the operation if the component gets remounted. + React.useEffect(() => { + const timeout = closeRef.current; + if (timeout != null) { + clearTimeout(timeout); + } + + return () => { + closeRef.current = setTimeout(() => { + wsRef.current?.close(); + }, 100); + }; + }, []); + + const value = React.useMemo( + () => ({ + status, + events, + send, + }), + [status, events], + ); + + return ( + + {children} + + ); +} + +export function useWsClient() { + const context = React.useContext(WsClientContext); + return context; +} diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index 8a6d4fac2dfc..4fe347f70335 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -10,7 +10,6 @@ import React, { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { Provider } from "react-redux"; import posthog from "posthog-js"; -import { SocketProvider } from "./context/socket"; import "./i18n"; import store from "./store"; @@ -43,12 +42,10 @@ prepareApp().then(() => hydrateRoot( document, - - - - - - + + + + , ); }), diff --git a/frontend/src/hooks/useTerminal.ts b/frontend/src/hooks/useTerminal.ts index b45618eeb17c..1409fdb7c4ca 100644 --- a/frontend/src/hooks/useTerminal.ts +++ b/frontend/src/hooks/useTerminal.ts @@ -4,7 +4,7 @@ import React from "react"; import { Command } from "#/state/commandSlice"; import { getTerminalCommand } from "#/services/terminalService"; import { parseTerminalOutput } from "#/utils/parseTerminalOutput"; -import { useSocket } from "#/context/socket"; +import { useWsClient } from "#/context/ws-client-provider"; /* NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. @@ -15,7 +15,7 @@ export const useTerminal = ( commands: Command[] = [], secrets: string[] = [], ) => { - const { send } = useSocket(); + const { send } = useWsClient(); const terminal = React.useRef(null); const fitAddon = React.useRef(null); const ref = React.useRef(null); diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 85f755901f9a..6881b8f500e1 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -535,7 +535,8 @@ "pt": "Socket não inicializado", "ko-KR": "소켓이 초기화되지 않았습니다", "ar": "لم يتم تهيئة Socket", - "tr": "Soket başlatılmadı" + "tr": "Soket başlatılmadı", + "no": "Socket ikke initialisert" }, "EXPLORER$UPLOAD_ERROR_MESSAGE": { "en": "Error uploading file", @@ -548,7 +549,8 @@ "pt": "Erro ao fazer upload do arquivo", "ko-KR": "파일 업로드 중 오류 발생", "ar": "خطأ في تحميل الملف", - "tr": "Dosya yüklenirken hata oluştu" + "tr": "Dosya yüklenirken hata oluştu", + "no": "Feil ved opplasting av fil" }, "EXPLORER$LABEL_DROP_FILES": { "en": "Drop files here", @@ -557,6 +559,7 @@ "zh-TW": "將檔案拖曳至此", "es": "Suelta los archivos aquí", "fr": "Déposez les fichiers ici", + "no": "Slipp filer her", "it": "Trascina i file qui", "pt": "Solte os arquivos aqui", "ko-KR": "파일을 여기에 놓으세요", @@ -574,7 +577,8 @@ "pt": "Espaço de trabalho", "ko-KR": "작업 공간", "ar": "مساحة العمل", - "tr": "Çalışma alanı" + "tr": "Çalışma alanı", + "no": "Arbeidsområde" }, "EXPLORER$EMPTY_WORKSPACE_MESSAGE": { "en": "No files in workspace", @@ -587,7 +591,8 @@ "pt": "Nenhum arquivo no espaço de trabalho", "ko-KR": "작업 공간에 파일이 없습니다", "ar": "لا توجد ملفات في مساحة العمل", - "tr": "Çalışma alanında dosya yok" + "tr": "Çalışma alanında dosya yok", + "no": "Ingen filer i arbeidsområdet" }, "EXPLORER$LOADING_WORKSPACE_MESSAGE": { "en": "Loading workspace...", @@ -600,7 +605,8 @@ "pt": "Carregando espaço de trabalho...", "ko-KR": "작업 공간 로딩 중...", "ar": "جارٍ تحميل مساحة العمل...", - "tr": "Çalışma alanı yükleniyor..." + "tr": "Çalışma alanı yükleniyor...", + "no": "Laster arbeidsområde..." }, "EXPLORER$REFRESH_ERROR_MESSAGE": { "en": "Error refreshing workspace", @@ -613,7 +619,8 @@ "pt": "Erro ao atualizar o espaço de trabalho", "ko-KR": "작업 공간 새로 고침 오류", "ar": "خطأ في تحديث مساحة العمل", - "tr": "Çalışma alanı yenilenirken hata oluştu" + "tr": "Çalışma alanı yenilenirken hata oluştu", + "no": "Feil ved oppdatering av arbeidsområde" }, "EXPLORER$UPLOAD_SUCCESS_MESSAGE": { "en": "Successfully uploaded {{count}} file(s)", @@ -626,7 +633,8 @@ "pt": "{{count}} arquivo(s) carregado(s) com sucesso", "ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다", "ar": "تم تحميل {{count}} ملف (ملفات) بنجاح", - "tr": "{{count}} dosya başarıyla yüklendi" + "tr": "{{count}} dosya başarıyla yüklendi", + "no": "Lastet opp {{count}} fil(er) vellykket" }, "EXPLORER$NO_FILES_UPLOADED_MESSAGE": { "en": "No files were uploaded", @@ -639,7 +647,8 @@ "pt": "Nenhum arquivo foi carregado", "ko-KR": "업로드된 파일이 없습니다", "ar": "لم يتم تحميل أي ملفات", - "tr": "Hiçbir dosya yüklenmedi" + "tr": "Hiçbir dosya yüklenmedi", + "no": "Ingen filer ble lastet opp" }, "EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": { "en": "{{count}} file(s) were skipped during upload", @@ -652,7 +661,8 @@ "pt": "{{count}} arquivo(s) foram ignorados durante o upload", "ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다", "ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل", - "tr": "Yükleme sırasında {{count}} dosya atlandı" + "tr": "Yükleme sırasında {{count}} dosya atlandı", + "no": "{{count}} fil(er) ble hoppet over under opplasting" }, "EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": { "en": "Unexpected response structure from server", @@ -665,7 +675,8 @@ "pt": "Estrutura de resposta inesperada do servidor", "ko-KR": "서버로부터 예상치 못한 응답 구조", "ar": "بنية استجابة غير متوقعة من الخادم", - "tr": "Sunucudan beklenmeyen yanıt yapısı" + "tr": "Sunucudan beklenmeyen yanıt yapısı", + "no": "Uventet responsstruktur fra serveren" }, "LOAD_SESSION$MODAL_TITLE": { "en": "Return to existing session?", @@ -799,95 +810,325 @@ }, "FEEDBACK$EMAIL_PLACEHOLDER": { "en": "Enter your email address", - "es": "Ingresa tu correo electrónico" + "es": "Ingresa tu correo electrónico", + "zh-CN": "输入您的电子邮件地址", + "zh-TW": "輸入您的電子郵件地址", + "ko-KR": "이메일 주소를 입력하세요", + "no": "Skriv inn din e-postadresse", + "ar": "أدخل عنوان بريدك الإلكتروني", + "de": "Geben Sie Ihre E-Mail-Adresse ein", + "fr": "Entrez votre adresse e-mail", + "it": "Inserisci il tuo indirizzo email", + "pt": "Digite seu endereço de e-mail", + "tr": "E-posta adresinizi girin" }, "FEEDBACK$PASSWORD_COPIED_MESSAGE": { "en": "Password copied to clipboard.", - "es": "Contraseña copiada al portapapeles." + "es": "Contraseña copiada al portapapeles.", + "zh-CN": "密码已复制到剪贴板。", + "zh-TW": "密碼已複製到剪貼板。", + "ko-KR": "비밀번호가 클립보드에 복사되었습니다.", + "no": "Passord kopiert til utklippstavlen.", + "ar": "تم نسخ كلمة المرور إلى الحافظة.", + "de": "Passwort in die Zwischenablage kopiert.", + "fr": "Mot de passe copié dans le presse-papiers.", + "it": "Password copiata negli appunti.", + "pt": "Senha copiada para a área de transferência.", + "tr": "Parola panoya kopyalandı." }, "FEEDBACK$GO_TO_FEEDBACK": { "en": "Go to shared feedback", - "es": "Ir a feedback compartido" + "es": "Ir a feedback compartido", + "zh-CN": "转到共享反馈", + "zh-TW": "前往共享反饋", + "ko-KR": "공유된 피드백으로 이동", + "no": "Gå til delt tilbakemelding", + "ar": "الذهاب إلى التعليقات المشتركة", + "de": "Zum geteilten Feedback gehen", + "fr": "Aller aux commentaires partagés", + "it": "Vai al feedback condiviso", + "pt": "Ir para feedback compartilhado", + "tr": "Paylaşılan geri bildirimlere git" }, "FEEDBACK$PASSWORD": { "en": "Password:", - "es": "Contraseña:" + "es": "Contraseña:", + "zh-CN": "密码:", + "zh-TW": "密碼:", + "ko-KR": "비밀번호:", + "no": "Passord:", + "ar": "كلمة المرور:", + "de": "Passwort:", + "fr": "Mot de passe :", + "it": "Password:", + "pt": "Senha:", + "tr": "Parola:" }, "FEEDBACK$INVALID_EMAIL_FORMAT": { "en": "Invalid email format", - "es": "Formato de correo inválido" + "es": "Formato de correo inválido", + "zh-CN": "无效的电子邮件格式", + "zh-TW": "無效的電子郵件格式", + "ko-KR": "잘못된 이메일 형식", + "no": "Ugyldig e-postformat", + "ar": "تنسيق البريد الإلكتروني غير صالح", + "de": "Ungültiges E-Mail-Format", + "fr": "Format d'e-mail invalide", + "it": "Formato email non valido", + "pt": "Formato de e-mail inválido", + "tr": "Geçersiz e-posta biçimi" }, "FEEDBACK$FAILED_TO_SHARE": { "en": "Failed to share, please contact the developers:", - "es": "Error al compartir, por favor contacta con los desarrolladores:" + "es": "Error al compartir, por favor contacta con los desarrolladores:", + "zh-CN": "分享失败,请联系开发人员:", + "zh-TW": "分享失敗,請聯繫開發人員:", + "ko-KR": "공유 실패, 개발자에게 문의하세요:", + "no": "Deling mislyktes, vennligst kontakt utviklerne:", + "ar": "فشل المشاركة، يرجى الاتصال بالمطورين:", + "de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:", + "fr": "Échec du partage, veuillez contacter les développeurs :", + "it": "Condivisione fallita, contattare gli sviluppatori:", + "pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:", + "tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:" }, "FEEDBACK$COPY_LABEL": { "en": "Copy", - "es": "Copiar" + "es": "Copiar", + "zh-CN": "复制", + "zh-TW": "複製", + "ko-KR": "복사", + "no": "Kopier", + "ar": "نسخ", + "de": "Kopieren", + "fr": "Copier", + "it": "Copia", + "pt": "Copiar", + "tr": "Kopyala" }, "FEEDBACK$SHARING_SETTINGS_LABEL": { "en": "Sharing settings", - "es": "Configuración de compartir" + "es": "Configuración de compartir", + "zh-CN": "共享设置", + "zh-TW": "共享設定", + "ko-KR": "공유 설정", + "no": "Delingsinnstillinger", + "ar": "إعدادات المشاركة", + "de": "Freigabeeinstellungen", + "fr": "Paramètres de partage", + "it": "Impostazioni di condivisione", + "pt": "Configurações de compartilhamento", + "tr": "Paylaşım ayarları" }, "SECURITY$UNKNOWN_ANALYZER_LABEL":{ "en": "Unknown security analyzer chosen", - "es": "Analizador de seguridad desconocido" + "es": "Analizador de seguridad desconocido", + "zh-CN": "选择了未知的安全分析器", + "zh-TW": "選擇了未知的安全分析器", + "ko-KR": "알 수 없는 보안 분석기가 선택되었습니다", + "no": "Ukjent sikkerhetsanalysator valgt", + "ar": "تم اختيار محلل أمان غير معروف", + "de": "Unbekannter Sicherheitsanalysator ausgewählt", + "fr": "Analyseur de sécurité inconnu choisi", + "it": "Analizzatore di sicurezza sconosciuto selezionato", + "pt": "Analisador de segurança desconhecido escolhido", + "tr": "Bilinmeyen güvenlik analizörü seçildi" }, "INVARIANT$UPDATE_POLICY_LABEL": { "en": "Update Policy", - "es": "Actualizar política" + "es": "Actualizar política", + "zh-CN": "更新策略", + "zh-TW": "更新策略", + "ko-KR": "정책 업데이트", + "no": "Oppdater policy", + "ar": "تحديث السياسة", + "de": "Richtlinie aktualisieren", + "fr": "Mettre à jour la politique", + "it": "Aggiorna policy", + "pt": "Atualizar política", + "tr": "İlkeyi güncelle" }, "INVARIANT$UPDATE_SETTINGS_LABEL": { "en": "Update Settings", - "es": "Actualizar configuración" + "es": "Actualizar configuración", + "zh-CN": "更新设置", + "zh-TW": "更新設定", + "ko-KR": "설정 업데이트", + "no": "Oppdater innstillinger", + "ar": "تحديث الإعدادات", + "de": "Einstellungen aktualisieren", + "fr": "Mettre à jour les paramètres", + "it": "Aggiorna impostazioni", + "pt": "Atualizar configurações", + "tr": "Ayarları güncelle" }, "INVARIANT$SETTINGS_LABEL": { "en": "Settings", - "es": "Configuración" + "es": "Configuración", + "zh-CN": "设置", + "zh-TW": "設定", + "ko-KR": "설정", + "no": "Innstillinger", + "ar": "الإعدادات", + "de": "Einstellungen", + "fr": "Paramètres", + "it": "Impostazioni", + "pt": "Configurações", + "tr": "Ayarlar" }, "INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": { "en": "Ask for user confirmation on risk severity:", - "es": "Preguntar por confirmación del usuario sobre severidad del riesgo:" + "es": "Preguntar por confirmación del usuario sobre severidad del riesgo:", + "zh-CN": "询问用户确认风险等级:", + "zh-TW": "詢問用戶確認風險等級:", + "ko-KR": "위험 심각도에 대한 사용자 확인 요청:", + "no": "Be om brukerbekreftelse på risikoalvorlighet:", + "ar": "اطلب تأكيد المستخدم على مستوى الخطورة:", + "de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:", + "fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :", + "it": "Chiedi conferma all'utente sulla gravità del rischio:", + "pt": "Solicitar confirmação do usuário sobre a gravidade do risco:", + "tr": "Risk şiddeti için kullanıcı onayı iste:" }, "INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": { "en": "Don't ask for confirmation", - "es": "No solicitar confirmación" + "es": "No solicitar confirmación", + "zh-CN": "不要请求确认", + "zh-TW": "不要請求確認", + "ko-KR": "확인 요청하지 않음", + "no": "Ikke spør om bekreftelse", + "ar": "لا تطلب التأكيد", + "de": "Nicht nach Bestätigung fragen", + "fr": "Ne pas demander de confirmation", + "it": "Non chiedere conferma", + "pt": "Não solicitar confirmação", + "tr": "Onay isteme" }, "INVARIANT$INVARIANT_ANALYZER_LABEL": { "en": "Invariant Analyzer", - "es": "Analizador de invariantes" + "es": "Analizador de invariantes", + "zh-CN": "不变量分析器", + "zh-TW": "不變量分析器", + "ko-KR": "불변성 분석기", + "no": "Invariant-analysator", + "ar": "محلل الثوابت", + "de": "Invarianten-Analysator", + "fr": "Analyseur d'invariants", + "it": "Analizzatore di invarianti", + "pt": "Analisador de invariantes", + "tr": "Değişmez Analizörü" }, "INVARIANT$INVARIANT_ANALYZER_MESSAGE": { "en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.", - "es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad." + "es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.", + "zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。", + "zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。", + "ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.", + "no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.", + "ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.", + "de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.", + "fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.", + "it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.", + "pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.", + "tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler." }, "INVARIANT$CLICK_TO_LEARN_MORE_LABEL": { "en": "Click to learn more", - "es": "Clic para aprender más" + "es": "Clic para aprender más", + "zh-CN": "点击了解更多", + "zh-TW": "點擊了解更多", + "ko-KR": "자세히 알아보기", + "no": "Klikk for å lære mer", + "ar": "انقر لمعرفة المزيد", + "de": "Klicken Sie, um mehr zu erfahren", + "fr": "Cliquez pour en savoir plus", + "it": "Clicca per saperne di più", + "pt": "Clique para saber mais", + "tr": "Daha fazla bilgi için tıklayın" }, "INVARIANT$POLICY_LABEL": { "en": "Policy", - "es": "Política" + "es": "Política", + "zh-CN": "策略", + "zh-TW": "策略", + "ko-KR": "정책", + "no": "Policy", + "ar": "السياسة", + "de": "Richtlinie", + "fr": "Politique", + "it": "Policy", + "pt": "Política", + "tr": "İlke" }, "INVARIANT$LOG_LABEL": { "en": "Logs", - "es": "Logs" + "es": "Logs", + "zh-CN": "日志", + "zh-TW": "日誌", + "ko-KR": "로그", + "no": "Logger", + "ar": "السجلات", + "de": "Protokolle", + "fr": "Journaux", + "it": "Log", + "pt": "Logs", + "tr": "Günlükler" }, "INVARIANT$EXPORT_TRACE_LABEL": { "en": "Export Trace", - "es": "Exportar traza" + "es": "Exportar traza", + "zh-CN": "导出跟踪", + "zh-TW": "匯出追蹤", + "ko-KR": "추적 내보내기", + "no": "Eksporter sporing", + "ar": "تصدير التتبع", + "de": "Ablaufverfolgung exportieren", + "fr": "Exporter la trace", + "it": "Esporta traccia", + "pt": "Exportar rastreamento", + "tr": "İzlemeyi dışa aktar" }, "INVARIANT$TRACE_EXPORTED_MESSAGE": { "en": "Trace exported", - "es": "Traza exportada" + "es": "Traza exportada", + "zh-CN": "跟踪已导出", + "zh-TW": "追蹤已匯出", + "ko-KR": "추적 내보내기 완료", + "no": "Sporing eksportert", + "ar": "تم تصدير التتبع", + "de": "Ablaufverfolgung exportiert", + "fr": "Trace exportée", + "it": "Traccia esportata", + "pt": "Rastreamento exportado", + "tr": "İzleme dışa aktarıldı" }, "INVARIANT$POLICY_UPDATED_MESSAGE": { "en": "Policy updated", - "es": "Política actualizada" + "es": "Política actualizada", + "zh-CN": "策略已更新", + "zh-TW": "策略已更新", + "ko-KR": "정책이 업데이트되었습니다", + "no": "Policy oppdatert", + "ar": "تم تحديث السياسة", + "de": "Richtlinie aktualisiert", + "fr": "Politique mise à jour", + "it": "Policy aggiornata", + "pt": "Política atualizada", + "tr": "İlke güncellendi" }, "INVARIANT$SETTINGS_UPDATED_MESSAGE": { "en": "Settings updated", - "es": "Configuración actualizada" + "es": "Configuración actualizada", + "zh-CN": "设置已更新", + "zh-TW": "設定已更新", + "ko-KR": "설정이 업데이트되었습니다", + "no": "Innstillinger oppdatert", + "ar": "تم تحديث الإعدادات", + "de": "Einstellungen aktualisiert", + "fr": "Paramètres mis à jour", + "it": "Impostazioni aggiornate", + "pt": "Configurações atualizadas", + "tr": "Ayarlar güncellendi" }, "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": { "en": "Starting up!", @@ -1276,7 +1517,8 @@ "pt": "Conversa de chat", "es": "Conversación de chat", "ar": "محادثة تلقيم", - "fr": "Conversation de chat" + "fr": "Conversation de chat", + "tr": "Sohbet Konuşması" }, "CHAT_INTERFACE$UNKNOWN_SENDER": { "en": "Unknown", diff --git a/frontend/src/assets/arrow-send.svg b/frontend/src/icons/arrow-send.svg similarity index 100% rename from frontend/src/assets/arrow-send.svg rename to frontend/src/icons/arrow-send.svg diff --git a/frontend/src/assets/build-it.svg b/frontend/src/icons/build-it.svg similarity index 100% rename from frontend/src/assets/build-it.svg rename to frontend/src/icons/build-it.svg diff --git a/frontend/src/assets/clip.svg b/frontend/src/icons/clip.svg similarity index 100% rename from frontend/src/assets/clip.svg rename to frontend/src/icons/clip.svg diff --git a/frontend/src/assets/clipboard.svg b/frontend/src/icons/clipboard.svg similarity index 100% rename from frontend/src/assets/clipboard.svg rename to frontend/src/icons/clipboard.svg diff --git a/frontend/src/assets/close.svg b/frontend/src/icons/close.svg similarity index 100% rename from frontend/src/assets/close.svg rename to frontend/src/icons/close.svg diff --git a/frontend/src/assets/cloud-connection.svg b/frontend/src/icons/cloud-connection.svg similarity index 100% rename from frontend/src/assets/cloud-connection.svg rename to frontend/src/icons/cloud-connection.svg diff --git a/frontend/src/assets/code.svg b/frontend/src/icons/code.svg similarity index 100% rename from frontend/src/assets/code.svg rename to frontend/src/icons/code.svg diff --git a/frontend/src/assets/default-user.svg b/frontend/src/icons/default-user.svg similarity index 100% rename from frontend/src/assets/default-user.svg rename to frontend/src/icons/default-user.svg diff --git a/frontend/src/assets/docs.svg b/frontend/src/icons/docs.svg similarity index 100% rename from frontend/src/assets/docs.svg rename to frontend/src/icons/docs.svg diff --git a/frontend/src/assets/ellipsis-h.svg b/frontend/src/icons/ellipsis-h.svg similarity index 100% rename from frontend/src/assets/ellipsis-h.svg rename to frontend/src/icons/ellipsis-h.svg diff --git a/frontend/src/assets/external-link.svg b/frontend/src/icons/external-link.svg similarity index 100% rename from frontend/src/assets/external-link.svg rename to frontend/src/icons/external-link.svg diff --git a/frontend/src/assets/globe.svg b/frontend/src/icons/globe.svg similarity index 100% rename from frontend/src/assets/globe.svg rename to frontend/src/icons/globe.svg diff --git a/frontend/src/assets/lightbulb.svg b/frontend/src/icons/lightbulb.svg similarity index 100% rename from frontend/src/assets/lightbulb.svg rename to frontend/src/icons/lightbulb.svg diff --git a/frontend/src/assets/list-type-number.svg b/frontend/src/icons/list-type-number.svg similarity index 100% rename from frontend/src/assets/list-type-number.svg rename to frontend/src/icons/list-type-number.svg diff --git a/frontend/src/assets/loading-outer.svg b/frontend/src/icons/loading-outer.svg similarity index 100% rename from frontend/src/assets/loading-outer.svg rename to frontend/src/icons/loading-outer.svg diff --git a/frontend/src/assets/message.svg b/frontend/src/icons/message.svg similarity index 100% rename from frontend/src/assets/message.svg rename to frontend/src/icons/message.svg diff --git a/frontend/src/assets/new-project.svg b/frontend/src/icons/new-project.svg similarity index 100% rename from frontend/src/assets/new-project.svg rename to frontend/src/icons/new-project.svg diff --git a/frontend/src/assets/play.svg b/frontend/src/icons/play.svg similarity index 100% rename from frontend/src/assets/play.svg rename to frontend/src/icons/play.svg diff --git a/frontend/src/assets/refresh.svg b/frontend/src/icons/refresh.svg similarity index 100% rename from frontend/src/assets/refresh.svg rename to frontend/src/icons/refresh.svg diff --git a/frontend/src/assets/send.svg b/frontend/src/icons/send.svg similarity index 100% rename from frontend/src/assets/send.svg rename to frontend/src/icons/send.svg diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 16f2f6972d04..5926dd8ac52e 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -71,8 +71,6 @@ const openHandsHandlers = [ export const handlers = [ ...openHandsHandlers, http.get("https://api.github.com/user/repos", async ({ request }) => { - if (import.meta.env.MODE !== "test") await delay(3500); - const token = request.headers .get("Authorization") ?.replace("Bearer", "") diff --git a/frontend/src/mocks/handlers.ws.ts b/frontend/src/mocks/handlers.ws.ts index da57339733f2..04ebeb6cea28 100644 --- a/frontend/src/mocks/handlers.ws.ts +++ b/frontend/src/mocks/handlers.ws.ts @@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({ action: "message", args: { content: message, - images_urls: [], + image_urls: [], wait_for_response: false, }, }); diff --git a/frontend/src/routes/_oh._index/hero-heading.tsx b/frontend/src/routes/_oh._index/hero-heading.tsx index d31177310494..f8dea1f89697 100644 --- a/frontend/src/routes/_oh._index/hero-heading.tsx +++ b/frontend/src/routes/_oh._index/hero-heading.tsx @@ -1,4 +1,4 @@ -import BuildIt from "#/assets/build-it.svg?react"; +import BuildIt from "#/icons/build-it.svg?react"; export function HeroHeading() { return ( diff --git a/frontend/src/routes/_oh.app._index/code-editor-component.tsx b/frontend/src/routes/_oh.app._index/code-editor-component.tsx index 8182805193a0..4cc26ad213a2 100644 --- a/frontend/src/routes/_oh.app._index/code-editor-component.tsx +++ b/frontend/src/routes/_oh.app._index/code-editor-component.tsx @@ -29,6 +29,10 @@ function CodeEditorCompoonent({ if (selectedPath && value) modifyFileContent(selectedPath, value); }; + const isBase64Image = (content: string) => content.startsWith("data:image/"); + const isPDF = (content: string) => content.startsWith("data:application/pdf"); + const isVideo = (content: string) => content.startsWith("data:video/"); + React.useEffect(() => { const handleSave = async (event: KeyboardEvent) => { if (selectedPath && event.metaKey && event.key === "s") { @@ -62,16 +66,40 @@ function CodeEditorCompoonent({ ); } + const fileContent = modifiedFiles[selectedPath] || files[selectedPath]; + + if (isBase64Image(fileContent)) { + return ( +
+ {selectedPath} +
+ ); + } + + if (isPDF(fileContent)) { + return ( +