mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
gitlab-doc
...
keycloak
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1786db475c | ||
|
|
b25477179e | ||
|
|
0e33601ffe | ||
|
|
3a047de788 | ||
|
|
cf4b2946a8 | ||
|
|
cd8cb25426 | ||
|
|
20e8ff8e73 | ||
|
|
f30d1178f6 | ||
|
|
53228643f9 | ||
|
|
7d6900c281 | ||
|
|
0bf05937b9 | ||
|
|
8f42db630d | ||
|
|
ae18002373 | ||
|
|
0f2584ebb0 | ||
|
|
cff81640af | ||
|
|
b777662c2e | ||
|
|
928870bc6c | ||
|
|
dfc32287a1 | ||
|
|
d94c65623b | ||
|
|
4b9545cb5b | ||
|
|
7d3dc563a1 | ||
|
|
13a602f467 | ||
|
|
9146010931 | ||
|
|
ab22f0ec89 | ||
|
|
14218421c4 | ||
|
|
0d0d132171 | ||
|
|
c28c323b49 | ||
|
|
5f2860f78e | ||
|
|
cce1e53434 |
@@ -373,7 +373,7 @@ Les options de configuration de l'agent sont définies dans les sections `[agent
|
||||
- Description : Si l'éditeur LLM est activé dans l'espace d'action (fonctionne uniquement avec l'appel de fonction)
|
||||
|
||||
**Utilisation du micro-agent**
|
||||
- `use_microagents`
|
||||
- `enable_prompt_extensions`
|
||||
- Type : `bool`
|
||||
- Valeur par défaut : `true`
|
||||
- Description : Indique si l'utilisation des micro-agents est activée ou non
|
||||
|
||||
@@ -8,6 +8,9 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
|
||||
|
||||
## Test if your environment works
|
||||
|
||||
Follow the instructions here https://miniwob.farama.org/content/getting_started/ & https://miniwob.farama.org/content/viewing/
|
||||
to set up MiniWoB server in your local environment at http://localhost:8080/miniwob/
|
||||
|
||||
Access with browser the above MiniWoB URLs and see if they load correctly.
|
||||
|
||||
## Run Evaluation
|
||||
|
||||
@@ -71,7 +71,7 @@ def process_git_patch(patch):
|
||||
return patch
|
||||
|
||||
|
||||
def get_config(instance: pd.Series) -> AppConfig:
|
||||
def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
|
||||
# We use a different instance image for the each instance of swe-bench eval
|
||||
base_container_image = get_instance_docker_image(instance['instance_id'])
|
||||
logger.info(
|
||||
@@ -132,7 +132,7 @@ def process_instance(
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
config = get_config(instance)
|
||||
config = get_config(metadata, instance)
|
||||
instance_id = instance.instance_id
|
||||
model_patch = instance['model_patch']
|
||||
test_spec: TestSpec = instance['test_spec']
|
||||
|
||||
@@ -158,6 +158,7 @@ def get_config(
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
@@ -8,7 +8,7 @@ import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
const renderSidebar = () => {
|
||||
describe("Sidebar", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
@@ -16,10 +16,9 @@ const renderSidebar = () => {
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
};
|
||||
const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
@@ -54,9 +53,16 @@ describe("Sidebar", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalledOnce();
|
||||
it("should fetch settings data on mount and if the user is authenticated", async () => {
|
||||
const authenticateSpy = vi.spyOn(OpenHands, "authenticate");
|
||||
|
||||
const { rerender } = renderSidebar();
|
||||
expect(getSettingsSpy).not.toHaveBeenCalled();
|
||||
|
||||
authenticateSpy.mockResolvedValueOnce(true);
|
||||
rerender(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
await waitFor(() => expect(getSettingsSpy).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("should send all settings data when saving AI configuration", async () => {
|
||||
@@ -156,7 +162,7 @@ describe("Sidebar", () => {
|
||||
await user.click(advancedOptionsSwitch);
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
|
||||
await user.type(apiKeyInput, "SET");
|
||||
await user.type(apiKeyInput, "**********");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
|
||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -52,7 +52,7 @@
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.64.2",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
@@ -5461,10 +5461,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/eslint-plugin-query": {
|
||||
"version": "5.62.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.16.tgz",
|
||||
"integrity": "sha512-VhnHSQ/hc62olLzGhlLJ4BJGWynwjs3cDMsByasKJ3zjW1YZ+6raxOv0gHHISm+VEnAY42pkMowmSWrXfL4NTw==",
|
||||
"version": "5.64.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.64.2.tgz",
|
||||
"integrity": "sha512-Xq7jRYvNtGMHjQEGUZLHgEMNB59hgTlqdmKor6cdJ6CMZ/nwmBGpnlr/dcHden7W7BPCdBVN4PWMZBICWvCNQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.18.1"
|
||||
},
|
||||
@@ -8739,10 +8740,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz",
|
||||
"integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==",
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz",
|
||||
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.9.1"
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.64.2",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
|
||||
@@ -1,23 +1,111 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { KeycloakErrorResponse } from "./open-hands.types";
|
||||
|
||||
export const openHands = axios.create();
|
||||
|
||||
export const setAuthTokenHeader = (token: string) => {
|
||||
console.debug(`setAuthTokenHeader to ${token}`);
|
||||
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
};
|
||||
|
||||
export const setGitHubTokenHeader = (token: string) => {
|
||||
console.debug(`setGitHubTokenHeader to ${token}`);
|
||||
openHands.defaults.headers.common["X-GitHub-Token"] = token;
|
||||
};
|
||||
|
||||
export const removeAuthTokenHeader = () => {
|
||||
console.debug("removeAuthTokenHeader");
|
||||
if (openHands.defaults.headers.common.Authorization) {
|
||||
delete openHands.defaults.headers.common.Authorization;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeGitHubTokenHeader = () => {
|
||||
console.debug("removeGitHubTokenHeader");
|
||||
if (openHands.defaults.headers.common["X-GitHub-Token"]) {
|
||||
delete openHands.defaults.headers.common["X-GitHub-Token"];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if response has attributes to perform refresh
|
||||
*/
|
||||
const canRefresh = (error: unknown): boolean =>
|
||||
!!(
|
||||
error instanceof AxiosError &&
|
||||
error.config &&
|
||||
error.response &&
|
||||
error.response.status &&
|
||||
error.response.data.keycloak_error
|
||||
);
|
||||
/**
|
||||
* Checks if the data is a Keycloak error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a Keycloak error response
|
||||
*/
|
||||
export const isKeycloakErrorResponse = <T extends object | Array<unknown>>(
|
||||
data: T | KeycloakErrorResponse | null,
|
||||
): data is KeycloakErrorResponse =>
|
||||
!!data && typeof data === "object" && "keycloak_error" in data;
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
export const setupOpenhandsAxiosInterceptors = (
|
||||
appMode: string,
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
openHands.interceptors.response.use(
|
||||
// Pass successful responses through
|
||||
(response) => {
|
||||
const parsedData = response.data;
|
||||
console.debug(
|
||||
`Openhands·API·call·response·to·${response.request.responseURL}\n·${JSON.stringify(parsedData)}`,
|
||||
);
|
||||
if (isKeycloakErrorResponse(parsedData)) {
|
||||
const error = new AxiosError(
|
||||
"Failed",
|
||||
"",
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
// Retry request exactly once if token is expired
|
||||
async (error) => {
|
||||
if (!canRefresh(error)) {
|
||||
return Promise.reject(new Error("Failed to refresh token"));
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error is due to an expired token
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
originalRequest.headers["X-GitHub-Token"] =
|
||||
openHands.defaults.headers.common["X-GitHub-Token"];
|
||||
return await openHands(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// If the error is not due to an expired token, propagate the error
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -147,7 +148,6 @@ class OpenHands {
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
): Promise<boolean> {
|
||||
if (appMode === "oss") return true;
|
||||
|
||||
const response =
|
||||
await openHands.post<AuthenticateResponse>("/api/authenticate");
|
||||
return response.status === 200;
|
||||
@@ -160,8 +160,17 @@ class OpenHands {
|
||||
static async refreshToken(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
if (appMode === "oss") return "";
|
||||
): Promise<{
|
||||
keycloakAccessToken: string;
|
||||
providerAccessToken: string;
|
||||
keycloakUserId: string;
|
||||
}> {
|
||||
if (appMode === "oss")
|
||||
return {
|
||||
keycloakAccessToken: "",
|
||||
providerAccessToken: "",
|
||||
keycloakUserId: "",
|
||||
};
|
||||
|
||||
const response = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/refresh-token",
|
||||
@@ -169,7 +178,11 @@ class OpenHands {
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return response.data.access_token;
|
||||
return {
|
||||
keycloakAccessToken: response.data.keycloakAccessToken,
|
||||
providerAccessToken: response.data.providerAccessToken,
|
||||
keycloakUserId: response.data.keycloakUserId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,13 +203,20 @@ class OpenHands {
|
||||
*/
|
||||
static async getGitHubAccessToken(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<GitHubAccessTokenResponse> {
|
||||
const { data } = await openHands.post<GitHubAccessTokenResponse>(
|
||||
const { data } = await openHands.get<GitHubAccessTokenResponse>(
|
||||
"/api/github/callback",
|
||||
{
|
||||
code,
|
||||
params: {
|
||||
code,
|
||||
redirectUri,
|
||||
},
|
||||
},
|
||||
);
|
||||
console.debug(
|
||||
`/api/github/callback response data: ${JSON.stringify(data)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -354,6 +374,15 @@ class OpenHands {
|
||||
|
||||
return response.data.items;
|
||||
}
|
||||
|
||||
static async getTrajectory(
|
||||
conversationId: string,
|
||||
): Promise<GetTrajectoryResponse> {
|
||||
const { data } = await openHands.get<GetTrajectoryResponse>(
|
||||
`/api/conversations/${conversationId}/trajectory`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -26,7 +26,9 @@ export interface FeedbackResponse {
|
||||
}
|
||||
|
||||
export interface GitHubAccessTokenResponse {
|
||||
access_token: string;
|
||||
keycloakAccessToken: string;
|
||||
providerAccessToken: string;
|
||||
keycloakUserId: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResponse {
|
||||
@@ -55,11 +57,20 @@ export interface GetVSCodeUrlResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetTrajectoryResponse {
|
||||
trajectory: unknown[] | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KeycloakErrorResponse {
|
||||
keycloak_error: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
title: string;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams } from "react-router";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { FeedbackActions } from "../feedback/feedback-actions";
|
||||
import { ExportActions } from "../export/export-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { addUserMessage } from "#/state/chat-slice";
|
||||
@@ -19,6 +22,8 @@ import { ActionSuggestions } from "./action-suggestions";
|
||||
import { ContinueButton } from "#/components/shared/buttons/continue-button";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { downloadTrajectory } from "#/utils/download-files";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -47,6 +52,8 @@ export function ChatInterface() {
|
||||
const { selectedRepository, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const params = useParams();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
if (messages.length === 0) {
|
||||
@@ -90,6 +97,25 @@ export function ChatInterface() {
|
||||
setFeedbackPolarity(polarity);
|
||||
};
|
||||
|
||||
const onClickExportTrajectoryButton = () => {
|
||||
if (!params.conversationId) {
|
||||
toast.error("ConversationId unknown, cannot download trajectory");
|
||||
return;
|
||||
}
|
||||
|
||||
getTrajectory(params.conversationId, {
|
||||
onSuccess: async (data) => {
|
||||
await downloadTrajectory(
|
||||
params.conversationId ?? "unknown",
|
||||
data.trajectory,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isWaitingForUserInput =
|
||||
curAgentState === AgentState.AWAITING_USER_INPUT ||
|
||||
curAgentState === AgentState.FINISHED;
|
||||
@@ -137,6 +163,9 @@ export function ChatInterface() {
|
||||
onClickShareFeedbackActionButton("negative")
|
||||
}
|
||||
/>
|
||||
<ExportActions
|
||||
onExportTrajectory={() => onClickExportTrajectoryButton()}
|
||||
/>
|
||||
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
|
||||
{messages.length > 2 &&
|
||||
|
||||
17
frontend/src/components/features/export/export-actions.tsx
Normal file
17
frontend/src/components/features/export/export-actions.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import ExportIcon from "#/icons/export.svg?react";
|
||||
import { ExportActionButton } from "#/components/shared/buttons/export-action-button";
|
||||
|
||||
interface ExportActionsProps {
|
||||
onExportTrajectory: () => void;
|
||||
}
|
||||
|
||||
export function ExportActions({ onExportTrajectory }: ExportActionsProps) {
|
||||
return (
|
||||
<div data-testid="export-actions" className="flex gap-1">
|
||||
<ExportActionButton
|
||||
onClick={onExportTrajectory}
|
||||
icon={<ExportIcon width={15} height={15} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
@@ -21,6 +20,7 @@ import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
export function Sidebar() {
|
||||
const dispatch = useDispatch();
|
||||
@@ -28,13 +28,7 @@ export function Sidebar() {
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
const { isUpToDate: settingsAreUpToDate, settings } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
@@ -66,6 +60,8 @@ export function Sidebar() {
|
||||
const showSettingsModal =
|
||||
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
|
||||
|
||||
console.warn({ isAuthed, settingsAreUpToDate, settingsModalIsOpen });
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
@@ -110,13 +106,12 @@ export function Sidebar() {
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
{showSettingsModal && (
|
||||
<SettingsModal
|
||||
settings={settings || DEFAULT_SETTINGS}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ interface WaitlistModalProps {
|
||||
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
if (ghToken) {
|
||||
return null; // Do not render the modal if ghToken is available
|
||||
}
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
handleCaptureConsent(true);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
interface ExportActionButtonProps {
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ExportActionButton({ onClick, icon }: ExportActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="button-base p-1 hover:bg-neutral-500"
|
||||
title="Export trajectory"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function AccountSettingsForm({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { gitHubToken, keycloakToken, setAccessTokens, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
@@ -38,10 +38,11 @@ export function AccountSettingsForm({
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
const kcToken = formData.get("kcToken")?.toString();
|
||||
const language = formData.get("language")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
if (ghToken && kcToken) setAccessTokens(ghToken, kcToken);
|
||||
|
||||
// The form returns the language label, so we need to find the corresponding
|
||||
// language key to save it in the settings
|
||||
@@ -95,6 +96,12 @@ export function AccountSettingsForm({
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<CustomInput
|
||||
name="kcToken"
|
||||
label="Keycloak Token"
|
||||
type="password"
|
||||
defaultValue={keycloakToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
|
||||
@@ -14,15 +14,15 @@ interface ConnectToGitHubModalProps {
|
||||
}
|
||||
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const { gitHubToken, setGitHubToken } = useAuth();
|
||||
const { gitHubToken, keycloakToken, setAccessTokens } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
const kcToken = formData.get("kcToken")?.toString();
|
||||
if (ghToken && kcToken) setAccessTokens(ghToken, kcToken);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -54,6 +54,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Keycloak Token"
|
||||
name="kcToken"
|
||||
required
|
||||
type="password"
|
||||
defaultValue={keycloakToken ?? ""}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
|
||||
@@ -171,7 +171,7 @@ export function SettingsForm({
|
||||
|
||||
<APIKeyInput
|
||||
isDisabled={!!disabled}
|
||||
isSet={settings.LLM_API_KEY === "SET"}
|
||||
isSet={settings.LLM_API_KEY === "**********"}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
|
||||
@@ -4,18 +4,23 @@ import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
|
||||
setupOpenhandsAxiosInterceptors,
|
||||
} from "#/api/open-hands-axios";
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
// setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
gitHubToken: string | null;
|
||||
keycloakToken: string | null;
|
||||
setUserId: (userId: string) => void;
|
||||
setGitHubToken: (token: string | null) => void;
|
||||
clearGitHubToken: () => void;
|
||||
setAccessTokens: (
|
||||
gitHubToken: string | null,
|
||||
keycloakToken: string | null,
|
||||
) => void;
|
||||
clearAccessTokens: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
@@ -27,69 +32,88 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
() => localStorage.getItem("ghToken"),
|
||||
);
|
||||
|
||||
const [userIdState, setUserIdState] = React.useState<string>(
|
||||
const [keycloakTokenState, setKeycloakTokenState] = React.useState<
|
||||
string | null
|
||||
>(() => localStorage.getItem("kcToken"));
|
||||
|
||||
const [, setUserIdState] = React.useState<string>(
|
||||
() => localStorage.getItem("userId") || "",
|
||||
);
|
||||
|
||||
const clearGitHubToken = () => {
|
||||
const clearAccessTokens = () => {
|
||||
console.debug("clearAccessTokens");
|
||||
setGitHubTokenState(null);
|
||||
setKeycloakTokenState(null);
|
||||
setUserIdState("");
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("kcToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
setGitHubTokenState(token);
|
||||
const setAccessTokens = (
|
||||
gitHubToken: string | null,
|
||||
keycloakToken: string | null,
|
||||
) => {
|
||||
console.debug(
|
||||
`setAccessTokens keycloakToken: ${keycloakToken}, githubToken: ${gitHubToken}`,
|
||||
);
|
||||
setGitHubTokenState(gitHubToken);
|
||||
setKeycloakTokenState(keycloakToken);
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("ghToken", token);
|
||||
setOpenHandsGitHubTokenHeader(token);
|
||||
setGitHubAuthTokenHeader(token);
|
||||
if (gitHubToken && keycloakToken) {
|
||||
localStorage.setItem("ghToken", gitHubToken);
|
||||
localStorage.setItem("kcToken", keycloakToken);
|
||||
setOpenHandsGitHubTokenHeader(keycloakToken);
|
||||
setGitHubAuthTokenHeader(gitHubToken);
|
||||
} else {
|
||||
clearGitHubToken();
|
||||
clearAccessTokens();
|
||||
}
|
||||
};
|
||||
|
||||
const setUserId = (userId: string) => {
|
||||
setUserIdState(userIdState);
|
||||
console.debug(`setUserId userId: ${userId}`);
|
||||
setUserIdState(userId);
|
||||
localStorage.setItem("userId", userId);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
clearAccessTokens();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
const config = await OpenHands.getConfig();
|
||||
// const config = await OpenHands.getConfig();
|
||||
|
||||
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
return false;
|
||||
}
|
||||
// if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
|
||||
if (newToken) {
|
||||
setGitHubToken(newToken);
|
||||
const storedUserid = localStorage.getItem("userId") || "";
|
||||
const data = await OpenHands.refreshToken("saas", storedUserid);
|
||||
if (data) {
|
||||
setAccessTokens(data.providerAccessToken, data.keycloakAccessToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearGitHubToken();
|
||||
clearAccessTokens();
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
const storedKeycloakToken = localStorage.getItem("kcToken");
|
||||
|
||||
const userId = localStorage.getItem("userId") || "";
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setAccessTokens(storedGitHubToken, storedKeycloakToken);
|
||||
setUserId(userId);
|
||||
const setupIntercepter = async () => {
|
||||
const config = await OpenHands.getConfig();
|
||||
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
setupOpenhandsAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
// setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
};
|
||||
|
||||
setupIntercepter();
|
||||
@@ -98,13 +122,14 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
gitHubToken: gitHubTokenState,
|
||||
setGitHubToken,
|
||||
keycloakToken: keycloakTokenState,
|
||||
setAccessTokens,
|
||||
setUserId,
|
||||
clearGitHubToken,
|
||||
clearAccessTokens,
|
||||
refreshToken,
|
||||
logout,
|
||||
}),
|
||||
[gitHubTokenState],
|
||||
[gitHubTokenState, keycloakTokenState],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
if (updatedSettings.LLM_API_KEY === "SET") {
|
||||
if (updatedSettings.LLM_API_KEY === "**********") {
|
||||
delete updatedSettings.LLM_API_KEY;
|
||||
}
|
||||
|
||||
|
||||
7
frontend/src/hooks/mutation/use-get-trajectory.ts
Normal file
7
frontend/src/hooks/mutation/use-get-trajectory.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGetTrajectory = () =>
|
||||
useMutation({
|
||||
mutationFn: (cid: string) => OpenHands.getTrajectory(cid),
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { gitHubToken, setUserId } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const user = useQuery({
|
||||
@@ -18,7 +18,7 @@ export const useGitHubUser = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.data) {
|
||||
setUserId(user.data.id.toString());
|
||||
// setUserId(user.data.id.toString());
|
||||
posthog.identify(user.data.login, {
|
||||
company: user.data.company,
|
||||
name: user.data.name,
|
||||
|
||||
@@ -4,6 +4,7 @@ import posthog from "posthog-js";
|
||||
import { AxiosError } from "axios";
|
||||
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
try {
|
||||
@@ -36,9 +37,12 @@ const getSettingsQueryFn = async () => {
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
enabled: !!isAuthed,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
5
frontend/src/icons/export.svg
Normal file
5
frontend/src/icons/export.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 16" fill="none">
|
||||
<path
|
||||
d="M11.875 9.5h-2.5V3.25c0-.16576-.0658-.32473-.1831-.44194-.1172-.11721-.276-.18306-.4419-.18306h-2.5c-.16576 0-.32473.06585-.44194.18306C5.68585 2.92527 5.62 3.08424 5.62 3.25V9.5h-2.5c-.13855 0-.27293.0483-.38002.1367-.10708.0883-.18294.2124-.21493.3508-.03199.1385-.01839.2839.03873.4142.05712.1304.15543.2397.27872.3108l4.375 2.5c.09664.0552.20607.0842.3175.0842.11144 0 .22087-.029.3175-.0842l4.375-2.5c.1233-.0711.2216-.1804.2787-.3108.0571-.1303.0707-.2757.0387-.4142-.032-.1384-.1078-.2625-.2149-.3508-.1071-.0884-.2415-.1367-.38-.1367zM3.75 13.375v1.25c0 .1658.06585.3247.18306.4419.11721.1172.27618.1831.44194.1831h6.25c.1657 0 .3247-.0659.4419-.1831.1172-.1172.1831-.2761.1831-.4419v-1.25c0-.1657-.0659-.3247-.1831-.4419-.1172-.1172-.2762-.1831-.4419-.1831h-6.25c-.16576 0-.32473.0659-.44194.1831C3.81585 13.0503 3.75 13.2093 3.75 13.375z"
|
||||
fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 963 B |
@@ -46,15 +46,14 @@ export function ErrorBoundary() {
|
||||
export default function MainApp() {
|
||||
useMaybeMigrateSettings();
|
||||
|
||||
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
|
||||
!localStorage.getItem("analytics-consent"),
|
||||
);
|
||||
|
||||
const { data: settings } = useSettings();
|
||||
const config = useConfig();
|
||||
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
|
||||
@@ -7,19 +7,24 @@ import { useAuth } from "#/context/auth-context";
|
||||
function OAuthGitHubCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setGitHubToken } = useAuth();
|
||||
const { setAccessTokens, setUserId } = useAuth();
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const requesterUrl = new URL(window.location.href);
|
||||
const redirectUrl = `${requesterUrl.origin}/oauth/github/callback`;
|
||||
|
||||
const { data, isSuccess, error } = useQuery({
|
||||
queryKey: ["access_token", code],
|
||||
queryFn: () => OpenHands.getGitHubAccessToken(code!),
|
||||
queryKey: ["access_token", code, redirectUrl],
|
||||
queryFn: () => OpenHands.getGitHubAccessToken(code!, redirectUrl),
|
||||
enabled: !!code,
|
||||
});
|
||||
|
||||
console.debug(`data: ${JSON.stringify(data)}, isSuccess: ${isSuccess}`);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSuccess) {
|
||||
setGitHubToken(data.access_token);
|
||||
setAccessTokens(data.providerAccessToken, data.keycloakAccessToken);
|
||||
setUserId(data.keycloakUserId);
|
||||
navigate("/");
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
12
frontend/src/types/file-system.d.ts
vendored
12
frontend/src/types/file-system.d.ts
vendored
@@ -26,6 +26,18 @@ interface FileSystemDirectoryHandle {
|
||||
): Promise<FileSystemFileHandle>;
|
||||
}
|
||||
|
||||
interface SaveFilePickerOptions {
|
||||
suggestedName?: string;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
excludeAcceptAllOption?: boolean;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
|
||||
showSaveFilePicker(
|
||||
options?: SaveFilePickerOptions,
|
||||
): Promise<FileSystemFileHandle>;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ function isFileSystemAccessSupported(): boolean {
|
||||
return "showDirectoryPicker" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Save File Picker API is supported
|
||||
*/
|
||||
function isSaveFilePickerSupported(): boolean {
|
||||
return "showSaveFilePicker" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates subdirectories and returns the final directory handle
|
||||
*/
|
||||
@@ -162,6 +169,39 @@ async function processBatch(
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadTrajectory(
|
||||
conversationId: string,
|
||||
data: unknown[] | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isSaveFilePickerSupported()) {
|
||||
throw new Error(
|
||||
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
suggestedName: `trajectory-${conversationId}.json`,
|
||||
types: [
|
||||
{
|
||||
description: "JSON File",
|
||||
accept: {
|
||||
"application/json": [".json"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handle = await window.showSaveFilePicker(options);
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to download file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads files from the workspace one by one
|
||||
* @param initialPath Initial path to start downloading from. If not provided, downloads from root
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
export const generateGitHubAuthUrl = (clientId: string, requestUrl: URL) => {
|
||||
const redirectUri = `${requestUrl.origin}/oauth/github/callback`;
|
||||
const scope = "repo,user,workflow,offline_access";
|
||||
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
// const scope = "repo,user,workflow,offline_access";
|
||||
console.debug(
|
||||
`http://localhost:8080/realms/allhandsgithub/protocol/openid-connect/auth?client_id=allhandsgithub&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid+email+profile&state=some-state-value&nonce=222`,
|
||||
);
|
||||
// return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
return `http://localhost:8080/realms/allhandsgithub/protocol/openid-connect/auth?client_id=allhandsgithub&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid+email+profile&state=some-state-value&nonce=222`;
|
||||
};
|
||||
|
||||
@@ -277,16 +277,11 @@ class CodeActAgent(Agent):
|
||||
# if it doesn't have tool call metadata, it was triggered by a user action
|
||||
if obs.tool_call_metadata is None:
|
||||
text = truncate_content(
|
||||
f'\nObserved result of command executed by user:\n{obs.content}',
|
||||
f'\nObserved result of command executed by user:\n{obs.to_agent_observation()}',
|
||||
max_message_chars,
|
||||
)
|
||||
else:
|
||||
text = truncate_content(
|
||||
obs.content
|
||||
+ f'\n[Python Interpreter: {obs.metadata.py_interpreter_path}]',
|
||||
max_message_chars,
|
||||
)
|
||||
text += f'\n[Command finished with exit code {obs.exit_code}]'
|
||||
text = truncate_content(obs.to_agent_observation(), max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, IPythonRunCellObservation):
|
||||
text = obs.content
|
||||
|
||||
@@ -80,7 +80,7 @@ IPythonTool = ChatCompletionToolParam(
|
||||
),
|
||||
)
|
||||
|
||||
_FILE_EDIT_DESCRIPTION = """Edit a file.
|
||||
_FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
|
||||
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
|
||||
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
|
||||
* IMPORTANT: For large files (e.g., > 300 lines), specify the range of lines to edit using `start` and `end` (1-indexed, inclusive). The range should be smaller than 300 lines.
|
||||
@@ -216,7 +216,7 @@ LLMBasedFileEditTool = ChatCompletionToolParam(
|
||||
),
|
||||
)
|
||||
|
||||
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files
|
||||
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
|
||||
* State is persistent across command calls and discussions with the user
|
||||
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
||||
* The `create` command cannot be used if the specified `path` already exists as a file
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* You should start exploring the file system with your view command, unless you need to explore more deeply.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
</IMPORTANT>
|
||||
|
||||
@@ -39,7 +39,7 @@ class SandboxConfig(BaseModel):
|
||||
|
||||
remote_runtime_api_url: str = Field(default='http://localhost:8000')
|
||||
local_runtime_url: str = Field(default='http://localhost')
|
||||
keep_runtime_alive: bool = Field(default=False)
|
||||
keep_runtime_alive: bool = Field(default=True)
|
||||
rm_all_containers: bool = Field(default=False)
|
||||
api_key: str | None = Field(default=None)
|
||||
base_container_image: str = Field(
|
||||
|
||||
@@ -192,7 +192,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
|
||||
custom_fields[k] = v
|
||||
merged_llm_dict = generic_llm_fields.copy()
|
||||
merged_llm_dict.update(custom_fields)
|
||||
|
||||
|
||||
custom_llm_config = LLMConfig(**merged_llm_dict)
|
||||
cfg.set_llm_config(custom_llm_config, nested_key)
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ class SensitiveDataFilter(logging.Filter):
|
||||
sensitive_patterns.extend(env_vars)
|
||||
|
||||
# and some special cases
|
||||
sensitive_patterns.append('JWT_SECRET')
|
||||
sensitive_patterns.append('JWT_SECRET_KEY')
|
||||
sensitive_patterns.append('LLM_API_KEY')
|
||||
sensitive_patterns.append('GITHUB_TOKEN')
|
||||
sensitive_patterns.append('SANDBOX_ENV_GITHUB_TOKEN')
|
||||
|
||||
@@ -149,16 +149,18 @@ class CmdOutputObservation(Observation):
|
||||
f'**CmdOutputObservation (source={self.source}, exit code={self.exit_code}, '
|
||||
f'metadata={json.dumps(self.metadata.model_dump(), indent=2)})**\n'
|
||||
'--BEGIN AGENT OBSERVATION--\n'
|
||||
f'{self._to_agent_observation()}\n'
|
||||
f'{self.to_agent_observation()}\n'
|
||||
'--END AGENT OBSERVATION--'
|
||||
)
|
||||
|
||||
def _to_agent_observation(self) -> str:
|
||||
def to_agent_observation(self) -> str:
|
||||
ret = f'{self.metadata.prefix}{self.content}{self.metadata.suffix}'
|
||||
if self.metadata.working_dir:
|
||||
ret += f'\n[Current working directory: {self.metadata.working_dir}]'
|
||||
if self.metadata.py_interpreter_path:
|
||||
ret += f'\n[Python interpreter: {self.metadata.py_interpreter_path}]'
|
||||
if self.metadata.exit_code != -1:
|
||||
ret += f'\n[Command finished with exit code {self.metadata.exit_code}]'
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
global _atexit_registered
|
||||
if not _atexit_registered:
|
||||
if not _atexit_registered and not config.sandbox.keep_runtime_alive:
|
||||
_atexit_registered = True
|
||||
atexit.register(remove_all_runtime_containers)
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
"""This runtime will connect to a remote oh-runtime-client."""
|
||||
|
||||
port: int = 60000 # default port for the remote runtime client
|
||||
runtime_id: str | None = None
|
||||
runtime_url: str | None = None
|
||||
_runtime_initialized: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -71,10 +74,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self.config.sandbox.api_key,
|
||||
self.session,
|
||||
)
|
||||
self.runtime_id: str | None = None
|
||||
self.runtime_url: str | None = None
|
||||
self.available_hosts: dict[str, int] = {}
|
||||
self._runtime_initialized: bool = False
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
|
||||
|
||||
@@ -27,6 +27,7 @@ from openhands.server.routes.manage_conversations import (
|
||||
from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
from openhands.server.routes.settings import app as settings_router
|
||||
from openhands.server.routes.trajectory import app as trajectory_router
|
||||
from openhands.server.shared import openhands_config, session_manager
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@@ -69,6 +70,7 @@ app.include_router(conversation_api_router)
|
||||
app.include_router(manage_conversation_api_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(github_api_router)
|
||||
app.include_router(trajectory_router)
|
||||
|
||||
AttachConversationMiddlewareImpl = get_impl(
|
||||
AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path
|
||||
|
||||
@@ -37,7 +37,11 @@ async def connect(connection_id: str, environ, auth):
|
||||
if not signed_token:
|
||||
logger.error('No github_auth cookie')
|
||||
raise ConnectionRefusedError('No github_auth cookie')
|
||||
decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
|
||||
if not config.jwt_secret:
|
||||
raise RuntimeError('JWT secret not found')
|
||||
decoded = jwt.decode(
|
||||
signed_token, config.jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
)
|
||||
user_id = decoded['github_user_id']
|
||||
|
||||
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
|
||||
|
||||
@@ -9,7 +9,7 @@ app = APIRouter(prefix='/api/github')
|
||||
|
||||
|
||||
def require_github_token(request: Request):
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
github_token = request.state.github_token
|
||||
if not github_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
40
openhands/server/routes/trajectory.py
Normal file
40
openhands/server/routes/trajectory.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.serialization import event_to_trajectory
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@app.get('/trajectory')
|
||||
async def get_trajectory(request: Request):
|
||||
"""Get trajectory.
|
||||
|
||||
This function retrieves the current trajectory and returns it.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request object.
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the trajectory as a list of
|
||||
events.
|
||||
"""
|
||||
try:
|
||||
async_stream = AsyncEventStreamWrapper(
|
||||
request.state.conversation.event_stream, filter_hidden=True
|
||||
)
|
||||
trajectory = []
|
||||
async for event in async_stream:
|
||||
trajectory.append(event_to_trajectory(event))
|
||||
return JSONResponse(status_code=200, content={'trajectory': trajectory})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting trajectory: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
'trajectory': None,
|
||||
'error': f'Error getting trajectory: {e}',
|
||||
},
|
||||
)
|
||||
@@ -11,7 +11,7 @@ def get_file_store(file_store: str, file_store_path: str | None = None) -> FileS
|
||||
raise ValueError('file_store_path is required for local file store')
|
||||
return LocalFileStore(file_store_path)
|
||||
elif file_store == 's3':
|
||||
return S3FileStore()
|
||||
return S3FileStore(file_store_path)
|
||||
elif file_store == 'google_cloud':
|
||||
return GoogleCloudFileStore(file_store_path)
|
||||
return InMemoryFileStore()
|
||||
|
||||
@@ -1,50 +1,130 @@
|
||||
import io
|
||||
import os
|
||||
|
||||
from minio import Minio
|
||||
import boto3
|
||||
import botocore
|
||||
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class S3FileStore(FileStore):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, bucket_name: str | None) -> None:
|
||||
access_key = os.getenv('AWS_ACCESS_KEY_ID')
|
||||
secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
|
||||
endpoint = os.getenv('AWS_S3_ENDPOINT', 's3.amazonaws.com')
|
||||
secure = os.getenv('AWS_S3_SECURE', 'true').lower() == 'true'
|
||||
self.bucket = os.getenv('AWS_S3_BUCKET')
|
||||
self.client = Minio(endpoint, access_key, secret_key, secure=secure)
|
||||
endpoint = self._ensure_url_scheme(secure, os.getenv('AWS_S3_ENDPOINT'))
|
||||
if bucket_name is None:
|
||||
bucket_name = os.environ['AWS_S3_BUCKET']
|
||||
self.bucket = bucket_name
|
||||
self.client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
endpoint_url=endpoint,
|
||||
use_ssl=secure,
|
||||
)
|
||||
|
||||
def write(self, path: str, contents: str | bytes) -> None:
|
||||
as_bytes = contents.encode('utf-8') if isinstance(contents, str) else contents
|
||||
stream = io.BytesIO(as_bytes)
|
||||
try:
|
||||
self.client.put_object(self.bucket, path, stream, len(as_bytes))
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f'Failed to write to S3 at path {path}: {e}')
|
||||
as_bytes = (
|
||||
contents.encode('utf-8') if isinstance(contents, str) else contents
|
||||
)
|
||||
self.client.put_object(Bucket=self.bucket, Key=path, Body=as_bytes)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
if e.response['Error']['Code'] == 'AccessDenied':
|
||||
raise FileNotFoundError(
|
||||
f"Error: Access denied to bucket '{self.bucket}'."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The bucket '{self.bucket}' does not exist."
|
||||
)
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to write to bucket '{self.bucket}' at path {path}: {e}"
|
||||
)
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
try:
|
||||
return self.client.get_object(self.bucket, path).data.decode('utf-8')
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=path)
|
||||
return response['Body'].read().decode('utf-8')
|
||||
except botocore.exceptions.ClientError as e:
|
||||
# Catch all S3-related errors
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The bucket '{self.bucket}' does not exist."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'NoSuchKey':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'."
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f'Failed to read from S3 at path {path}: {e}')
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
|
||||
)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
if path and path != '/' and not path.endswith('/'):
|
||||
path += '/'
|
||||
try:
|
||||
return [
|
||||
obj.object_name for obj in self.client.list_objects(self.bucket, path)
|
||||
]
|
||||
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
|
||||
# Check if 'Contents' exists in the response
|
||||
if 'Contents' in response:
|
||||
objects = [obj['Key'] for obj in response['Contents']]
|
||||
return objects
|
||||
else:
|
||||
return list()
|
||||
except botocore.exceptions.ClientError as e:
|
||||
# Catch all S3-related errors
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The bucket '{self.bucket}' does not exist."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'AccessDenied':
|
||||
raise FileNotFoundError(
|
||||
f"Error: Access denied to bucket '{self.bucket}'."
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError(f"Error: {e.response['Error']['Message']}")
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f'Failed to list S3 objects at path {path}: {e}')
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
|
||||
)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
try:
|
||||
client = self.client
|
||||
bucket = self.bucket
|
||||
objects_to_delete = client.list_objects(bucket, prefix=path, recursive=True)
|
||||
for obj in objects_to_delete:
|
||||
client.remove_object(bucket, obj.object_name)
|
||||
self.client.delete_object(Bucket=self.bucket, Key=path)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The bucket '{self.bucket}' does not exist."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'AccessDenied':
|
||||
raise FileNotFoundError(
|
||||
f"Error: Access denied to bucket '{self.bucket}'."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'NoSuchKey':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'."
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}': {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f'Failed to delete S3 object at path {path}: {e}')
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}: {e}"
|
||||
)
|
||||
|
||||
def _ensure_url_scheme(self, secure: bool, url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
if secure:
|
||||
if not url.startswith('https://'):
|
||||
url = 'https://' + url.removeprefix('http://')
|
||||
else:
|
||||
if not url.startswith('http://'):
|
||||
url = 'http://' + url.removeprefix('https://')
|
||||
return url
|
||||
|
||||
@@ -100,7 +100,9 @@ class EmbeddingsLoader:
|
||||
return AzureOpenAIEmbedding(
|
||||
model='text-embedding-ada-002',
|
||||
deployment_name=llm_config.embedding_deployment_name,
|
||||
api_key=llm_config.api_key,
|
||||
api_key=llm_config.api_key.get_secret_value()
|
||||
if llm_config.api_key
|
||||
else None,
|
||||
azure_endpoint=llm_config.base_url,
|
||||
api_version=llm_config.api_version,
|
||||
)
|
||||
|
||||
1245
poetry.lock
generated
1245
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ openhands-aci = "0.1.8"
|
||||
python-socketio = "^5.11.4"
|
||||
redis = "^5.2.0"
|
||||
sse-starlette = "^2.1.3"
|
||||
pre-commit = "^4.0.1"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
@@ -84,7 +85,7 @@ llama-index-embeddings-voyageai = "*"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.9.2"
|
||||
mypy = "1.14.1"
|
||||
pre-commit = "4.0.1"
|
||||
pre-commit = "4.1.0"
|
||||
build = "*"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
|
||||
@@ -46,7 +46,7 @@ def agent() -> CodeActAgent:
|
||||
agent = CodeActAgent(llm=LLM(LLMConfig()), config=config)
|
||||
agent.llm = Mock()
|
||||
agent.llm.config = Mock()
|
||||
agent.llm.config.max_message_chars = 100
|
||||
agent.llm.config.max_message_chars = 1000
|
||||
return agent
|
||||
|
||||
|
||||
@@ -65,10 +65,15 @@ def test_cmd_output_observation_message(agent: CodeActAgent):
|
||||
content='Command output',
|
||||
metadata=CmdOutputMetadata(
|
||||
exit_code=0,
|
||||
prefix='[THIS IS PREFIX]',
|
||||
suffix='[THIS IS SUFFIX]',
|
||||
),
|
||||
)
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
tool_call_id_to_message = {}
|
||||
results = agent.get_observation_message(
|
||||
obs, tool_call_id_to_message=tool_call_id_to_message
|
||||
)
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
@@ -76,8 +81,10 @@ def test_cmd_output_observation_message(agent: CodeActAgent):
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'Command output' in result.content[0].text
|
||||
assert 'Observed result of command executed by user:' in result.content[0].text
|
||||
assert '[Command finished with exit code 0]' in result.content[0].text
|
||||
assert '[THIS IS PREFIX]' in result.content[0].text
|
||||
assert '[THIS IS SUFFIX]' in result.content[0].text
|
||||
|
||||
|
||||
def test_ipython_run_cell_observation_message(agent: CodeActAgent):
|
||||
|
||||
Reference in New Issue
Block a user