Compare commits

..

17 Commits

Author SHA1 Message Date
openhands f25bda360f Fix issue #4896: [Bug]: Fix failing workflows 2024-11-12 11:07:25 +00:00
sp.wack de71b7cdb8 test(frontend): Fix failing e2e test due to mock delay (#4923) 2024-11-12 10:50:38 +00:00
sp.wack 04aeccfb69 fix(frontend): Remove quotes from suggestion (#4921) 2024-11-12 12:30:43 +02:00
Faraz Shamim 4eea1286d4 Issue #4399 : Replaced all occurences (#4878)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-12 10:58:09 +01:00
Robert Brennan 488a320ffd update to use github client lib (#4909) 2024-11-12 00:56:50 +00:00
Robert Brennan 377fadc2eb fix remote runtimes (#4902) 2024-11-12 00:02:34 +00:00
Robert Brennan 7df7f43e3c Revert "Add rate limiting to server endpoints" (#4910) 2024-11-11 23:26:49 +00:00
Engel Nyst a45aba512a Tweak log levels (#4729) 2024-11-11 22:51:56 +00:00
tofarr a1a9d2f175 Refactor websocket (#4879)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-11 22:36:07 +00:00
Robert Brennan 79492b6551 Add rate limiting to server endpoints (#4867)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-11 16:54:22 -05:00
sp.wack 80fdb9a2f4 feat(posthog): Emit user activated event (#4886) 2024-11-11 23:31:41 +02:00
Nafis Reza 975e75531d Move assets/icons to dedicated folder (#4850) 2024-11-11 20:17:04 +00:00
Robert Brennan 1b5f5bcdad fixes for upcoming changes to remote API (#4834) 2024-11-11 14:51:14 -05:00
Rohit Malhotra 8c00d96024 Support displaying images/videos/pdfs in the workspace (#4898) 2024-11-11 20:22:17 +02:00
Robert Brennan bf8ccc8fc3 fix infinite loop (#4873)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-11 10:59:43 +00:00
OpenHands 037d770f66 Fix issue #4884: (chore) add missing FE translations (#4885)
Co-authored-by: tobitege <10787084+tobitege@users.noreply.github.com>
2024-11-11 10:09:46 +00:00
sp.wack dd50246672 test(frontend): Pass failing tests (#4887) 2024-11-11 09:49:56 +00:00
82 changed files with 1022 additions and 640 deletions
+1
View File
@@ -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
@@ -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 \
+1
View File
@@ -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
@@ -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<typeof import("#/context/socket")>()),
useSocket: useSocketMock,
...(await importActual<typeof import("#/context/ws-client-provider")>()),
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
}));
+16 -4
View File
@@ -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 <div ref={ref} />;
}
interface WrapperProps {
children: ReactNode;
}
function Wrapper({children}: WrapperProps) {
return (
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
)
}
describe("useTerminal", () => {
const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(),
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
wrapper: SocketProvider,
wrapper: Wrapper,
});
});
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
];
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
wrapper: SocketProvider,
wrapper: Wrapper,
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
secrets={[secret, anotherSecret]}
/>,
{
wrapper: SocketProvider,
wrapper: Wrapper,
},
);
@@ -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: "/",
});
@@ -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",
-11
View File
@@ -1,11 +0,0 @@
> openhands-frontend@0.13.0 dev
> npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev
> openhands-frontend@0.13.0 make-i18n
> node scripts/make-i18n-translations.cjs
➜ Local: http://localhost:3001/
➜ Network: use --host to expose
➜ press h + enter to show help
+2 -2
View File
@@ -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<string, AgentState[]> = {
[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) => {
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react";
import Clip from "#/icons/clip.svg?react";
export function AttachImageLabel() {
return (
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
@@ -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);
+188
View File
@@ -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<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
const initialQueryRef = React.useRef<string | null>(
store.getState().initalQuery.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const data = useRouteLoaderData<typeof rootClientLoader>("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;
}
+1 -1
View File
@@ -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 {
@@ -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";
@@ -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);
@@ -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 {
@@ -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";
@@ -1,4 +1,4 @@
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
interface ScrollToBottomButtonProps {
onClick: () => void;
@@ -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;
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react";
import Clip from "#/icons/clip.svg?react";
interface UploadImageInputProps {
onUpload: (files: File[]) => void;
+1 -1
View File
@@ -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 {
-146
View File
@@ -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<Data>) => 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<string, unknown>[];
}
const SocketContext = React.createContext<WebSocketContextType | undefined>(
undefined,
);
interface SocketProviderProps {
children: React.ReactNode;
}
function SocketProvider({ children }: SocketProviderProps) {
const wsRef = React.useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
const [runtimeActive, setRuntimeActive] = React.useState(false);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
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 (
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
);
}
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 };
+175
View File
@@ -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<string, unknown>[];
send: (event: Record<string, unknown>) => void;
}
const WsClientContext = React.createContext<UseWsClient>({
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<WsClientProviderProps>) {
const wsRef = React.useRef<WebSocket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
function send(event: Record<string, unknown>) {
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<UseWsClient>(
() => ({
status,
events,
send,
}),
[status, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
}
export function useWsClient() {
const context = React.useContext(WsClientContext);
return context;
}
+4 -7
View File
@@ -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,
<StrictMode>
<SocketProvider>
<Provider store={store}>
<RemixBrowser />
<PosthogInit />
</Provider>
</SocketProvider>
<Provider store={store}>
<RemixBrowser />
<PosthogInit />
</Provider>
</StrictMode>,
);
}),
+2 -6
View File
@@ -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<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
@@ -101,10 +101,6 @@ export const useTerminal = (
if (commandBuffer.length > 0) {
commandBuffer = handleBackspace(commandBuffer);
}
} else if (domEvent.key === "Tab") {
// Swallow tab key and convert to space
commandBuffer += " ";
terminal.current?.write(" ");
} else {
// Ignore paste event
if (key.charCodeAt(0) === 22) {
+276 -34
View File
@@ -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",

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 662 B

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 319 B

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 924 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 856 B

Before

Width:  |  Height:  |  Size: 811 B

After

Width:  |  Height:  |  Size: 811 B

-2
View File
@@ -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", "")
+1 -1
View File
@@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({
action: "message",
args: {
content: message,
images_urls: [],
image_urls: [],
wait_for_response: false,
},
});
@@ -1,4 +1,4 @@
import BuildIt from "#/assets/build-it.svg?react";
import BuildIt from "#/icons/build-it.svg?react";
export function HeroHeading() {
return (
@@ -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 (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img src={fileContent} alt={selectedPath} className="object-contain" />
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
return (
<Editor
data-testid="code-editor"
path={selectedPath ?? undefined}
defaultValue=""
value={
selectedPath
? modifiedFiles[selectedPath] || files[selectedPath]
: undefined
}
value={selectedPath ? fileContent : undefined}
onMount={onMount}
onChange={handleEditorChange}
options={{ readOnly: isReadOnly }}
+64 -245
View File
@@ -2,71 +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 { 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 "#/assets/code.svg?react";
import GlobeIcon from "#/assets/globe.svg?react";
import ListIcon from "#/assets/list-type-number.svg?react";
import { createChatMessage } from "#/services/chatService";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
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");
@@ -116,174 +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 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,
@@ -291,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>
<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 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>
);
}
+5 -17
View File
@@ -21,12 +21,11 @@ import { DangerModal } from "#/components/modals/confirmation-modals/danger-moda
import { LoadingSpinner } from "#/components/modals/LoadingProject";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions";
import { useSocket } from "#/context/socket";
import i18n from "#/i18n";
import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react";
import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/icons/docs.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal";
@@ -135,7 +134,6 @@ type SettingsFormData = {
};
export default function MainApp() {
const { stop, isConnected } = useSocket();
const navigation = useNavigation();
const location = useLocation();
const {
@@ -202,14 +200,6 @@ export default function MainApp() {
}
}, [user]);
React.useEffect(() => {
if (location.pathname === "/") {
// If the user is on the home page, we should stop the socket connection.
// This is relevant when the user redirects here for whatever reason.
if (isConnected) stop();
}
}, [location.pathname]);
const handleUserLogout = () => {
logoutFetcher.submit(
{},
@@ -313,11 +303,9 @@ export default function MainApp() {
<p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account
</p>
{isConnected && (
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
)}
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
<SettingsForm
settings={settings}
models={settingsFormData.models}
+13 -18
View File
@@ -12,8 +12,11 @@ import {
import { setCurStatusMessage } from "#/state/statusSlice";
import store from "#/store";
import ActionType from "#/types/ActionType";
import { ActionMessage, StatusMessage } from "#/types/Message";
import { SocketMessage } from "#/types/ResponseType";
import {
ActionMessage,
ObservationMessage,
StatusMessage,
} from "#/types/Message";
import { handleObservationMessage } from "./observations";
const messageActions = {
@@ -138,22 +141,14 @@ export function handleStatusMessage(message: StatusMessage) {
}
}
export function handleAssistantMessage(data: string | SocketMessage) {
let socketMessage: SocketMessage;
if (typeof data === "string") {
socketMessage = JSON.parse(data) as SocketMessage;
export function handleAssistantMessage(message: Record<string, unknown>) {
if (message.action) {
handleActionMessage(message as unknown as ActionMessage);
} else if (message.observation) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else {
socketMessage = data;
}
if ("action" in socketMessage) {
handleActionMessage(socketMessage);
} else if ("observation" in socketMessage) {
handleObservationMessage(socketMessage);
} else if ("status_update" in socketMessage) {
handleStatusMessage(socketMessage);
} else {
console.error("Unknown message type", socketMessage);
console.error("Unknown message type", message);
}
}
+4 -5
View File
@@ -1,8 +1,7 @@
import ActionType from "#/types/ActionType";
import AgentState from "#/types/AgentState";
export const generateAgentStateChangeEvent = (state: AgentState) =>
JSON.stringify({
action: ActionType.CHANGE_AGENT_STATE,
args: { agent_state: state },
});
export const generateAgentStateChangeEvent = (state: AgentState) => ({
action: ActionType.CHANGE_AGENT_STATE,
args: { agent_state: state },
});
+1 -1
View File
@@ -63,7 +63,7 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
if (response?.status === 401 && !url.startsWith("/api/authenticate")) {
await request(
"/api/authenticate",
{
+3 -3
View File
@@ -2,12 +2,12 @@ import ActionType from "#/types/ActionType";
export function createChatMessage(
message: string,
images_urls: string[],
image_urls: string[],
timestamp: string,
) {
const event = {
action: ActionType.MESSAGE,
args: { content: message, images_urls, timestamp },
args: { content: message, image_urls, timestamp },
};
return JSON.stringify(event);
return event;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import ActionType from "#/types/ActionType";
export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } };
return JSON.stringify(event);
return event;
}
export function getGitHubTokenCommand(gitHubToken: string) {
+2 -2
View File
@@ -4,7 +4,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
source: "user";
args: {
content: string;
images_urls: string[];
image_urls: string[];
};
}
@@ -23,7 +23,7 @@ export interface AssistantMessageAction
source: "agent";
args: {
content: string;
images_urls: string[] | null;
image_urls: string[] | null;
wait_for_response: boolean;
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ interface LocalUserMessageAction {
action: "message";
args: {
content: string;
images_urls: string[];
image_urls: string[];
};
}
@@ -26,6 +26,7 @@ import { extractModelAndProvider } from "./extractModelAndProvider";
*/
export const organizeModelsAndProviders = (models: string[]) => {
const object: Record<string, { separator: string; models: string[] }> = {};
models.forEach((model) => {
const {
separator,
@@ -45,5 +46,6 @@ export const organizeModelsAndProviders = (models: string[]) => {
}
object[key].models.push(modelId);
});
return object;
};
@@ -13,14 +13,14 @@ const KEY_2 = "Auto-merge Dependabot PRs";
const VALUE_2 = `Please add a GitHub action to this repository which automatically merges pull requests from Dependabot so long as the tests are passing.`;
const KEY_3 = "Fix up my README";
const VALUE_3 = `"Please look at the README and make the following improvements, if they make sense:
const VALUE_3 = `Please look at the README and make the following improvements, if they make sense:
* correct any typos that you find
* add missing language annotations on codeblocks
* if there are references to other files or other sections of the README, turn them into links
* make sure the readme has an h1 title towards the top
* make sure any existing sections in the readme are appropriately separated with headings
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier"`;
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier`;
const KEY_4 = "Clean up my dependencies";
const VALUE_4 = `Examine the dependencies of the current codebase. Make sure you can run the code and any tests.
+1
View File
@@ -20,6 +20,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2",
"claude-2.1",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
+2 -2
View File
@@ -6,7 +6,7 @@ import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
import { AppStore, RootState, rootReducer } from "./src/store";
import { SocketProvider } from "#/context/socket";
import { WsClientProvider } from "#/context/ws-client-provider";
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
@@ -35,7 +35,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
return (
<Provider store={store}>
<SocketProvider>{children}</SocketProvider>
<WsClientProvider enabled={true} token={null} ghToken={null} settings={null}>{children}</WsClientProvider>
</Provider>
);
}
@@ -196,8 +196,8 @@ class CodeActAgent(Agent):
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
if self.llm.vision_is_active() and action.images_urls:
content.append(ImageContent(image_urls=action.images_urls))
if self.llm.vision_is_active() and action.image_urls:
content.append(ImageContent(image_urls=action.image_urls))
return [
Message(
role=role,
@@ -95,9 +95,9 @@ class CodeActSWEAgent(Agent):
if (
self.llm.vision_is_active()
and isinstance(action, MessageAction)
and action.images_urls
and action.image_urls
):
content.append(ImageContent(image_urls=action.images_urls))
content.append(ImageContent(image_urls=action.image_urls))
return Message(
role='user' if action.source == 'user' else 'assistant', content=content
+16 -5
View File
@@ -1,5 +1,6 @@
import asyncio
import copy
import os
import traceback
from typing import Callable, ClassVar, Type
@@ -259,7 +260,11 @@ class AgentController:
observation_to_print.content = truncate_content(
observation_to_print.content, self.agent.llm.config.max_message_chars
)
self.log('debug', str(observation_to_print), extra={'msg_type': 'OBSERVATION'})
# Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(
log_level, str(observation_to_print), extra={'msg_type': 'OBSERVATION'}
)
if observation.llm_metrics is not None:
self.agent.llm.metrics.merge(observation.llm_metrics)
@@ -282,8 +287,12 @@ class AgentController:
action (MessageAction): The message action to handle.
"""
if action.source == EventSource.USER:
# Use info level if LOG_ALL_EVENTS is set
log_level = (
'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
)
self.log(
'debug',
log_level,
str(action),
extra={'msg_type': 'ACTION', 'event_source': EventSource.USER},
)
@@ -497,7 +506,9 @@ class AgentController:
await self.update_state_after_step()
self.log('debug', str(action), extra={'msg_type': 'ACTION'})
# Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
async def _delegate_step(self):
"""Executes a single step of the delegate agent."""
@@ -663,7 +674,7 @@ class AgentController:
# sanity check
if start_id > end_id + 1:
self.log(
'debug',
'warning',
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
)
self.state.history = []
@@ -694,7 +705,7 @@ class AgentController:
# Match with most recent unmatched delegate action
if not delegate_action_ids:
self.log(
'error',
'warning',
f'Found AgentDelegateObservation without matching action at id={event.id}',
)
continue
+1 -1
View File
@@ -149,7 +149,7 @@ class State:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == 'user':
last_user_message = event.content
last_user_message_image_urls = event.images_urls
last_user_message_image_urls = event.image_urls
elif isinstance(event, AgentFinishAction):
if last_user_message is not None:
return last_user_message, None
+2 -2
View File
@@ -177,7 +177,7 @@ class SensitiveDataFilter(logging.Filter):
return True
def get_console_handler(log_level=logging.INFO, extra_info: str | None = None):
def get_console_handler(log_level: int = logging.INFO, extra_info: str | None = None):
"""Returns a console handler for logging."""
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level)
@@ -188,7 +188,7 @@ def get_console_handler(log_level=logging.INFO, extra_info: str | None = None):
return console_handler
def get_file_handler(log_dir, log_level=logging.INFO):
def get_file_handler(log_dir: str, log_level: int = logging.INFO):
"""Returns a file handler for logging."""
os.makedirs(log_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y-%m-%d')
+11 -3
View File
@@ -7,7 +7,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
@dataclass
class MessageAction(Action):
content: str
images_urls: list[str] | None = None
image_urls: list[str] | None = None
wait_for_response: bool = False
action: str = ActionType.MESSAGE
security_risk: ActionSecurityRisk | None = None
@@ -16,10 +16,18 @@ class MessageAction(Action):
def message(self) -> str:
return self.content
@property
def images_urls(self):
# Deprecated alias for backward compatibility
return self.image_urls
@images_urls.setter
def images_urls(self, value):
self.image_urls = value
def __str__(self) -> str:
ret = f'**MessageAction** (source={self.source})\n'
ret += f'CONTENT: {self.content}'
if self.images_urls:
for url in self.images_urls:
if self.image_urls:
for url in self.image_urls:
ret += f'\nIMAGE_URL: {url}'
return ret
+4
View File
@@ -66,6 +66,10 @@ def action_from_dict(action: dict) -> Action:
if is_confirmed is not None:
args['confirmation_state'] = is_confirmed
# images_urls has been renamed to image_urls
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
try:
decoded_action = action_class(**args)
if 'timeout' in action:
+1 -1
View File
@@ -101,7 +101,7 @@ def event_to_memory(event: 'Event', max_message_chars: int) -> dict:
d.pop('cause', None)
d.pop('timestamp', None)
d.pop('message', None)
d.pop('images_urls', None)
d.pop('image_urls', None)
# runnable actions have some extra fields used in the BE/FE, which should not be sent to the LLM
if 'args' in d:
@@ -7,7 +7,9 @@ NOTE: this will be executed inside the docker sandbox.
import argparse
import asyncio
import base64
import io
import mimetypes
import os
import shutil
import tempfile
@@ -217,6 +219,33 @@ class ActionExecutor:
working_dir = self.bash_session.workdir
filepath = self._resolve_path(action.path, working_dir)
try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file:
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'image/png' # default to PNG if mime type cannot be determined
encoded_image = f'data:{mime_type};base64,{encoded_image}'
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file:
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file:
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'video/mp4' # default to MP4 if MIME type cannot be determined
encoded_video = f'data:{mime_type};base64,{encoded_video}'
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
@@ -137,7 +137,7 @@ class RemoteRuntime(Runtime):
try:
response = self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.sid}',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
timeout=5,
)
except requests.HTTPError as e:
@@ -227,7 +227,7 @@ class RemoteRuntime(Runtime):
'command': command,
'working_dir': '/openhands/code/',
'environment': {'DEBUG': 'true'} if self.config.debug else {},
'runtime_id': self.sid,
'session_id': self.sid,
}
# Start the sandbox using the /start endpoint
@@ -276,7 +276,7 @@ class RemoteRuntime(Runtime):
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
runtime_info_response = self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
)
runtime_data = runtime_info_response.json()
assert 'runtime_id' in runtime_data
+22 -31
View File
@@ -1,10 +1,12 @@
import os
import httpx
from github import Github
from github.GithubException import GithubException
from tenacity import retry, stop_after_attempt, wait_exponential
from openhands.core.logger import openhands_logger as logger
from openhands.server.sheets_client import GoogleSheetsClient
from openhands.utils.async_utils import call_sync_from_async
GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '').strip()
GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
@@ -12,7 +14,7 @@ GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
class UserVerifier:
def __init__(self) -> None:
logger.info('Initializing UserVerifier')
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
@@ -25,7 +27,7 @@ class UserVerifier:
"""Load users from text file if configured"""
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
if not waitlist:
logger.info('GITHUB_USER_LIST_FILE not configured')
logger.debug('GITHUB_USER_LIST_FILE not configured')
return
if not os.path.exists(waitlist):
@@ -46,10 +48,10 @@ class UserVerifier:
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.info('GITHUB_USERS_SHEET_ID not configured')
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.info('Initializing Google Sheets integration')
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
@@ -61,21 +63,21 @@ class UserVerifier:
if not self.is_active():
return True
logger.info(f'Checking if GitHub user {username} is allowed')
logger.debug(f'Checking if GitHub user {username} is allowed')
if self.file_users:
if username in self.file_users:
logger.info(f'User {username} found in text file allowlist')
logger.debug(f'User {username} found in text file allowlist')
return True
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id)
if username in sheet_users:
logger.info(f'User {username} found in Google Sheets allowlist')
logger.debug(f'User {username} found in Google Sheets allowlist')
return True
logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.info(f'User {username} not found in any allowlist')
logger.debug(f'User {username} not found in any allowlist')
return False
@@ -83,10 +85,10 @@ async def authenticate_github_user(auth_token) -> bool:
user_verifier = UserVerifier()
if not user_verifier.is_active():
logger.info('No user verification sources configured - allowing all users')
logger.debug('No user verification sources configured - allowing all users')
return True
logger.info('Checking GitHub token')
logger.debug('Checking GitHub token')
if not auth_token:
logger.warning('No GitHub token provided')
@@ -112,25 +114,14 @@ async def get_github_user(token: str) -> str:
Returns:
github handle of the user
"""
logger.info('Fetching GitHub user info from token')
headers = {
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {token}',
}
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0)
) as client:
try:
response = await client.get('https://api.github.com/user', headers=headers)
except httpx.RequestError as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
logger.info('Received response from GitHub API')
logger.debug(f'Response status code: {response.status_code}')
response.raise_for_status()
user_data = response.json()
login = user_data.get('login')
logger.debug('Fetching GitHub user info from token')
try:
g = Github(token)
user = await call_sync_from_async(g.get_user)
login = user.login
logger.info(f'Successfully retrieved GitHub user: {login}')
return login
except GithubException as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
+1 -1
View File
@@ -276,7 +276,7 @@ async def websocket_endpoint(websocket: WebSocket):
```
- Send a message:
```json
{"action": "message", "args": {"content": "Hello, how are you?", "images_urls": ["base64_url1", "base64_url2"]}}
{"action": "message", "args": {"content": "Hello, how are you?", "image_urls": ["base64_url1", "base64_url2"]}}
```
- Write contents to a file:
```json
+1 -1
View File
@@ -163,7 +163,7 @@ class Session:
return
event = event_from_dict(data.copy())
# This checks if the model supports images
if isinstance(event, MessageAction) and event.images_urls:
if isinstance(event, MessageAction) and event.image_urls:
controller = self.agent_session.controller
if controller:
if controller.agent.llm.config.disable_vision:
Generated
+51 -1
View File
@@ -6778,6 +6778,25 @@ files = [
{file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
]
[[package]]
name = "pygithub"
version = "2.5.0"
description = "Use the full Github API v3"
optional = false
python-versions = ">=3.8"
files = [
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
]
[package.dependencies]
Deprecated = "*"
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
pynacl = ">=1.4.0"
requests = ">=2.14.0"
typing-extensions = ">=4.0.0"
urllib3 = ">=1.26.0"
[[package]]
name = "pygments"
version = "2.18.0"
@@ -6842,6 +6861,32 @@ files = [
[package.dependencies]
pybind11 = ">=2.2"
[[package]]
name = "pynacl"
version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"},
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"},
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"},
{file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"},
{file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"},
{file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"},
]
[package.dependencies]
cffi = ">=1.4.1"
[package.extras]
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyparsing"
version = "3.2.0"
@@ -7995,6 +8040,11 @@ files = [
{file = "scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd"},
{file = "scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:394397841449853c2290a32050382edaec3da89e35b3e03d6cc966aebc6a8ae6"},
{file = "scikit_learn-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:57cc1786cfd6bd118220a92ede80270132aa353647684efa385a74244a41e3b1"},
{file = "scikit_learn-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9a702e2de732bbb20d3bad29ebd77fc05a6b427dc49964300340e4c9328b3f5"},
{file = "scikit_learn-1.5.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:b0768ad641981f5d3a198430a1d31c3e044ed2e8a6f22166b4d546a5116d7908"},
{file = "scikit_learn-1.5.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:178ddd0a5cb0044464fc1bfc4cca5b1833bfc7bb022d70b05db8530da4bb3dd3"},
{file = "scikit_learn-1.5.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7284ade780084d94505632241bf78c44ab3b6f1e8ccab3d2af58e0e950f9c12"},
{file = "scikit_learn-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:b7b0f9a0b1040830d38c39b91b3a44e1b643f4b36e36567b80b7c6bd2202a27f"},
{file = "scikit_learn-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:757c7d514ddb00ae249832fe87100d9c73c6ea91423802872d9e74970a0e40b9"},
{file = "scikit_learn-1.5.2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:52788f48b5d8bca5c0736c175fa6bdaab2ef00a8f536cda698db61bd89c551c1"},
{file = "scikit_learn-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:643964678f4b5fbdc95cbf8aec638acc7aa70f5f79ee2cdad1eec3df4ba6ead8"},
@@ -10128,4 +10178,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "8a34ef6158ca2a9fe3615fc362db3fd71bc43eabb57ffc2e2e14dfb658cf52c3"
content-hash = "245fd4cd56a3c95b2dd4f3a06251f7de82ad0300de7349f0710aac1f92a151b7"
+41 -38
View File
@@ -12,65 +12,66 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12"
datasets = "*"
pandas = "*"
datasets = "2.15.0"
pandas = "2.1.3"
litellm = "^1.51.1"
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "*" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
seaborn = "*"
docker = "*"
fastapi = "*"
toml = "*"
uvicorn = "*"
types-toml = "*"
numpy = "*"
json-repair = "*"
google-generativeai = "0.3.2"
google-api-python-client = "2.111.0"
google-auth-httplib2 = "0.1.1"
google-auth-oauthlib = "1.1.0"
termcolor = "2.4.0"
seaborn = "0.13.0"
docker = "7.0.0"
fastapi = "0.104.1"
toml = "0.10.2"
uvicorn = "0.24.0"
types-toml = "0.10.8.7"
numpy = "1.26.2"
json-repair = "0.9.0"
browsergym = "0.10.2" # integrate browsergym as the browsing interface
html2text = "*"
html2text = "2.1.1"
e2b = "^0.17.1"
pexpect = "*"
pexpect = "4.9.0"
jinja2 = "^3.1.3"
python-multipart = "*"
boto3 = "*"
python-multipart = "0.0.6"
boto3 = "1.33.6"
minio = "^7.2.8"
gevent = "^24.2.1"
pyarrow = "17.0.0" # transitive dependency, pinned here to avoid conflicts
pyarrow = "14.0.1"
tenacity = "^8.5.0"
zope-interface = "7.1.1"
zope-interface = "6.1"
pathspec = "^0.12.1"
google-cloud-aiplatform = "*"
anthropic = {extras = ["vertex"], version = "*"}
google-cloud-aiplatform = "1.36.4"
anthropic = {extras = ["vertex"], version = "0.8.1"}
grep-ast = "0.3.3"
tree-sitter = "0.21.3"
bashlex = "^0.18"
pyjwt = "^2.9.0"
dirhash = "*"
dirhash = "0.2.1"
python-frontmatter = "^1.1.0"
python-docx = "*"
PyPDF2 = "*"
python-pptx = "*"
pylatexenc = "*"
tornado = "*"
python-dotenv = "*"
python-docx = "1.1.0"
PyPDF2 = "3.0.1"
python-pptx = "0.6.22"
pylatexenc = "2.10"
tornado = "6.4"
python-dotenv = "1.0.0"
pylcs = "^0.1.1"
whatthepatch = "^1.0.6"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
protobuf = "4.21.12"
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = "^0.64.145"
runloop-api-client = "0.7.0"
pygithub = "^2.5.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"
llama-index-vector-stores-chroma = "*"
chromadb = "*"
llama-index-embeddings-huggingface = "*"
torch = "2.5.0"
llama-index-embeddings-azure-openai = "*"
llama-index-embeddings-ollama = "*"
llama-index = "0.9.15"
llama-index-vector-stores-chroma = "0.1.4"
chromadb = "0.4.22"
llama-index-embeddings-huggingface = "0.1.4"
torch = "2.1.1"
llama-index-embeddings-azure-openai = "0.1.4"
llama-index-embeddings-ollama = "0.1.4"
[tool.poetry.group.dev.dependencies]
ruff = "0.7.1"
@@ -93,6 +94,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -123,6 +125,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"
+2 -2
View File
@@ -65,7 +65,7 @@ def test_event_props_serialization_deserialization():
'action': 'message',
'args': {
'content': 'This is a test.',
'images_urls': None,
'image_urls': None,
'wait_for_response': False,
},
}
@@ -77,7 +77,7 @@ def test_message_action_serialization_deserialization():
'action': 'message',
'args': {
'content': 'This is a test.',
'images_urls': None,
'image_urls': None,
'wait_for_response': False,
},
}
+2 -2
View File
@@ -17,7 +17,7 @@ def test_event_serialization_deserialization():
'message': 'This is a test.',
'args': {
'content': 'This is a test.',
'images_urls': None,
'image_urls': None,
'wait_for_response': False,
},
}
@@ -38,7 +38,7 @@ def test_array_serialization_deserialization():
'message': 'This is a test.',
'args': {
'content': 'This is a test.',
'images_urls': None,
'image_urls': None,
'wait_for_response': False,
},
}