mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04547bccba | |||
| c92ed34a6e | |||
| e588b6a0d3 | |||
| 6281484a17 | |||
| 982d4f27b6 | |||
| 48aa4e852a | |||
| 1a3354404e | |||
| bbab2b3f07 | |||
| 41f53180a0 | |||
| 80279f9d36 | |||
| 0b3d15a4d7 | |||
| 8b68d086f0 | |||
| 0f143a43c9 | |||
| e61e4d57d9 | |||
| c0a1939ce3 | |||
| aece495170 | |||
| 50a15d964e | |||
| 19be0fe699 | |||
| 91dd4cd2b2 | |||
| 4e86bdf3d9 | |||
| 3cef499b81 | |||
| e2a0884ecd | |||
| 2849974729 | |||
| daa4af18d1 |
@@ -41,3 +41,8 @@ Frontend:
|
||||
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
|
||||
- Internationalization:
|
||||
- Generate i18n declaration file: `npm run make-i18n`
|
||||
|
||||
|
||||
## Template for Github Pull Request
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
|
||||
@@ -57,7 +57,7 @@ docker run -it --rm --pull=always \
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> On a public network? See our [Hardened Docker Installation](https://docs.all-hands.dev/modules/usage/runtimes/docker#hardened-docker-installation) guide
|
||||
> On a public network? See our [Hardened Docker Installation](https://docs.all-hands.dev/modules/usage/runtimes/docker#hardened-docker-installation) guide
|
||||
> to secure your deployment by restricting network binding and implementing additional security measures.
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -21,4 +21,4 @@ OpenHands supports several different runtime environments:
|
||||
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta)
|
||||
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal
|
||||
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona
|
||||
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker
|
||||
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker
|
||||
|
||||
@@ -29,4 +29,4 @@ bash -i <(curl -sL https://get.daytona.io/openhands)
|
||||
|
||||
Once executed, OpenHands should be running locally and ready for use.
|
||||
|
||||
For more details and manual initialization, view the entire [README.md](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/impl/daytona/README.md)
|
||||
For more details and manual initialization, view the entire [README.md](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/impl/daytona/README.md)
|
||||
|
||||
@@ -59,4 +59,4 @@ The Local Runtime is particularly useful for:
|
||||
- CI/CD pipelines where Docker is not available.
|
||||
- Testing and development of OpenHands itself.
|
||||
- Environments where container usage is restricted.
|
||||
- Scenarios where direct file system access is required.
|
||||
- Scenarios where direct file system access is required.
|
||||
|
||||
@@ -10,4 +10,4 @@ docker run # ...
|
||||
-e RUNTIME=modal \
|
||||
-e MODAL_API_TOKEN_ID="your-id" \
|
||||
-e MODAL_API_TOKEN_SECRET="your-secret" \
|
||||
```
|
||||
```
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes in parallel in the cloud.
|
||||
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
|
||||
|
||||
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
|
||||
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
|
||||
|
||||
@@ -53,6 +53,7 @@ fi
|
||||
if [ -n "$AGENT_CONFIG" ]; then
|
||||
echo "AGENT_CONFIG: $AGENT_CONFIG"
|
||||
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
+12
-2
@@ -26,7 +26,12 @@ describe("ConversationPanel", () => {
|
||||
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {}
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
usage: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { endSessionMock } = vi.hoisted(() => ({
|
||||
@@ -340,7 +345,12 @@ describe("ConversationPanel", () => {
|
||||
]);
|
||||
|
||||
renderWithProviders(<MyRouterStub />, {
|
||||
preloadedState: {}
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
usage: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const toggleButton = screen.getByText("Toggle");
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMetrics } from "#/hooks/query/use-metrics";
|
||||
import { vi, describe, it } from "vitest";
|
||||
|
||||
// Mock the query-redux-bridge
|
||||
vi.mock("#/utils/query-redux-bridge", () => ({
|
||||
getQueryReduxBridge: vi.fn(() => ({
|
||||
getReduxSliceState: vi.fn(() => ({
|
||||
cost: null,
|
||||
usage: null,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Skip tests for now due to JSX parsing issues
|
||||
describe("useMetrics", () => {
|
||||
it("should return initial metrics state", () => {
|
||||
// Test implementation
|
||||
});
|
||||
|
||||
it("should update metrics state", async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,6 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as CustomToast from "#/utils/custom-toast-handlers";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { initQueryReduxBridge } from "#/utils/query-redux-bridge";
|
||||
|
||||
describe("App", () => {
|
||||
const errorToastSpy = vi.spyOn(CustomToast, "displayErrorToast");
|
||||
@@ -20,10 +18,6 @@ describe("App", () => {
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// Initialize the QueryReduxBridge for tests
|
||||
const queryClient = new QueryClient();
|
||||
initQueryReduxBridge(queryClient);
|
||||
|
||||
vi.mock("#/hooks/use-end-session", () => ({
|
||||
useEndSession: vi.fn(() => endSessionMock),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,6 @@ import store from "#/store";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
import * as queryReduxBridge from "#/utils/query-redux-bridge";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/utils/error-handler", () => ({
|
||||
@@ -17,22 +16,13 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock QueryReduxBridge
|
||||
vi.mock("#/utils/query-redux-bridge", () => ({
|
||||
getQueryReduxBridge: vi.fn(() => ({
|
||||
isSliceMigrated: vi.fn(() => true),
|
||||
syncReduxToQuery: vi.fn(),
|
||||
conditionalDispatch: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Actions Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleStatusMessage", () => {
|
||||
it("should handle info messages without dispatching to Redux (now using React Query)", () => {
|
||||
it("should dispatch info messages to status state", () => {
|
||||
const message = {
|
||||
type: "info",
|
||||
message: "Runtime is not available",
|
||||
@@ -42,8 +32,9 @@ describe("Actions Service", () => {
|
||||
|
||||
handleStatusMessage(message);
|
||||
|
||||
// We no longer dispatch to Redux for info messages
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: message,
|
||||
}));
|
||||
});
|
||||
|
||||
it("should log error messages and display them in chat", () => {
|
||||
@@ -69,54 +60,6 @@ describe("Actions Service", () => {
|
||||
});
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
it("should update metrics via React Query when metrics are available", () => {
|
||||
const message: ActionMessage = {
|
||||
id: 1,
|
||||
action: ActionType.MESSAGE,
|
||||
source: "agent",
|
||||
message: "Test message",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: {
|
||||
content: "Test content",
|
||||
},
|
||||
llm_metrics: {
|
||||
accumulated_cost: 0.05,
|
||||
},
|
||||
tool_call_metadata: {
|
||||
model_response: {
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockBridge = {
|
||||
isSliceMigrated: vi.fn(() => true),
|
||||
syncReduxToQuery: vi.fn(),
|
||||
conditionalDispatch: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(queryReduxBridge.getQueryReduxBridge).mockReturnValue(mockBridge as any);
|
||||
|
||||
handleActionMessage(message);
|
||||
|
||||
expect(mockBridge.isSliceMigrated).toHaveBeenCalledWith("metrics");
|
||||
expect(mockBridge.syncReduxToQuery).toHaveBeenCalledWith(
|
||||
["metrics"],
|
||||
{
|
||||
cost: 0.05,
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should use first-person perspective for task completion messages", () => {
|
||||
// Test partial completion
|
||||
const messagePartial: ActionMessage = {
|
||||
|
||||
@@ -9,67 +9,67 @@ describe("formatTimeDelta", () => {
|
||||
|
||||
it("formats the yearly time correctly", () => {
|
||||
const oneYearAgo = new Date("2023-01-01T00:00:00Z");
|
||||
expect(formatTimeDelta(oneYearAgo)).toBe("1 year");
|
||||
expect(formatTimeDelta(oneYearAgo)).toBe("1y");
|
||||
|
||||
const twoYearsAgo = new Date("2022-01-01T00:00:00Z");
|
||||
expect(formatTimeDelta(twoYearsAgo)).toBe("2 years");
|
||||
expect(formatTimeDelta(twoYearsAgo)).toBe("2y");
|
||||
|
||||
const threeYearsAgo = new Date("2021-01-01T00:00:00Z");
|
||||
expect(formatTimeDelta(threeYearsAgo)).toBe("3 years");
|
||||
expect(formatTimeDelta(threeYearsAgo)).toBe("3y");
|
||||
});
|
||||
|
||||
it("formats the monthly time correctly", () => {
|
||||
const oneMonthAgo = new Date("2023-12-01T00:00:00Z");
|
||||
expect(formatTimeDelta(oneMonthAgo)).toBe("1 month");
|
||||
expect(formatTimeDelta(oneMonthAgo)).toBe("1mo");
|
||||
|
||||
const twoMonthsAgo = new Date("2023-11-01T00:00:00Z");
|
||||
expect(formatTimeDelta(twoMonthsAgo)).toBe("2 months");
|
||||
expect(formatTimeDelta(twoMonthsAgo)).toBe("2mo");
|
||||
|
||||
const threeMonthsAgo = new Date("2023-10-01T00:00:00Z");
|
||||
expect(formatTimeDelta(threeMonthsAgo)).toBe("3 months");
|
||||
expect(formatTimeDelta(threeMonthsAgo)).toBe("3mo");
|
||||
});
|
||||
|
||||
it("formats the daily time correctly", () => {
|
||||
const oneDayAgo = new Date("2023-12-31T00:00:00Z");
|
||||
expect(formatTimeDelta(oneDayAgo)).toBe("1 day");
|
||||
expect(formatTimeDelta(oneDayAgo)).toBe("1d");
|
||||
|
||||
const twoDaysAgo = new Date("2023-12-30T00:00:00Z");
|
||||
expect(formatTimeDelta(twoDaysAgo)).toBe("2 days");
|
||||
expect(formatTimeDelta(twoDaysAgo)).toBe("2d");
|
||||
|
||||
const threeDaysAgo = new Date("2023-12-29T00:00:00Z");
|
||||
expect(formatTimeDelta(threeDaysAgo)).toBe("3 days");
|
||||
expect(formatTimeDelta(threeDaysAgo)).toBe("3d");
|
||||
});
|
||||
|
||||
it("formats the hourly time correctly", () => {
|
||||
const oneHourAgo = new Date("2023-12-31T23:00:00Z");
|
||||
expect(formatTimeDelta(oneHourAgo)).toBe("1 hour");
|
||||
expect(formatTimeDelta(oneHourAgo)).toBe("1h");
|
||||
|
||||
const twoHoursAgo = new Date("2023-12-31T22:00:00Z");
|
||||
expect(formatTimeDelta(twoHoursAgo)).toBe("2 hours");
|
||||
expect(formatTimeDelta(twoHoursAgo)).toBe("2h");
|
||||
|
||||
const threeHoursAgo = new Date("2023-12-31T21:00:00Z");
|
||||
expect(formatTimeDelta(threeHoursAgo)).toBe("3 hours");
|
||||
expect(formatTimeDelta(threeHoursAgo)).toBe("3h");
|
||||
});
|
||||
|
||||
it("formats the minute time correctly", () => {
|
||||
const oneMinuteAgo = new Date("2023-12-31T23:59:00Z");
|
||||
expect(formatTimeDelta(oneMinuteAgo)).toBe("1 minute");
|
||||
expect(formatTimeDelta(oneMinuteAgo)).toBe("1m");
|
||||
|
||||
const twoMinutesAgo = new Date("2023-12-31T23:58:00Z");
|
||||
expect(formatTimeDelta(twoMinutesAgo)).toBe("2 minutes");
|
||||
expect(formatTimeDelta(twoMinutesAgo)).toBe("2m");
|
||||
|
||||
const threeMinutesAgo = new Date("2023-12-31T23:57:00Z");
|
||||
expect(formatTimeDelta(threeMinutesAgo)).toBe("3 minutes");
|
||||
expect(formatTimeDelta(threeMinutesAgo)).toBe("3m");
|
||||
});
|
||||
|
||||
it("formats the second time correctly", () => {
|
||||
const oneSecondAgo = new Date("2023-12-31T23:59:59Z");
|
||||
expect(formatTimeDelta(oneSecondAgo)).toBe("1 second");
|
||||
expect(formatTimeDelta(oneSecondAgo)).toBe("1s");
|
||||
|
||||
const twoSecondsAgo = new Date("2023-12-31T23:59:58Z");
|
||||
expect(formatTimeDelta(twoSecondsAgo)).toBe("2 seconds");
|
||||
expect(formatTimeDelta(twoSecondsAgo)).toBe("2s");
|
||||
|
||||
const threeSecondsAgo = new Date("2023-12-31T23:59:57Z");
|
||||
expect(formatTimeDelta(threeSecondsAgo)).toBe("3 seconds");
|
||||
expect(formatTimeDelta(threeSecondsAgo)).toBe("3s");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# Redux to React Query Migration Guide
|
||||
|
||||
This guide outlines the process for migrating from Redux to React Query in our application.
|
||||
|
||||
## Overview
|
||||
|
||||
The migration strategy allows for a gradual transition from Redux to React Query, with the ability to migrate one slice at a time without breaking the application. This is achieved through a bridge that coordinates between Redux and React Query.
|
||||
|
||||
## Key Components
|
||||
|
||||
1. **QueryReduxBridge**: A utility class that manages the migration state and coordinates between Redux and React Query.
|
||||
2. **Websocket Integration**: Modified to respect migration flags and update the appropriate state management system.
|
||||
3. **React Query Hooks**: New hooks that replace Redux slice functionality.
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Initialize the Bridge
|
||||
|
||||
In your main application file (e.g., `App.tsx`), initialize the bridge:
|
||||
|
||||
```tsx
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { initQueryReduxBridge } from '#/utils/query-redux-bridge';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
...queryClientConfig,
|
||||
});
|
||||
|
||||
// Initialize the bridge
|
||||
initQueryReduxBridge(queryClient);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* Your app components */}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Replace the WebSocket Provider
|
||||
|
||||
Replace the original WebSocket provider with the bridge-aware version:
|
||||
|
||||
```tsx
|
||||
import { WsClientProviderWithBridge } from '#/context/ws-client-provider-with-bridge';
|
||||
|
||||
// Instead of
|
||||
// <WsClientProvider conversationId={conversationId}>
|
||||
// {children}
|
||||
// </WsClientProvider>
|
||||
|
||||
// Use
|
||||
<WsClientProviderWithBridge conversationId={conversationId}>
|
||||
{children}
|
||||
</WsClientProviderWithBridge>
|
||||
```
|
||||
|
||||
### 3. Add the WebSocket Events Hook
|
||||
|
||||
Add the WebSocket events hook to your application to handle events for React Query:
|
||||
|
||||
```tsx
|
||||
import { useWebsocketEvents } from '#/hooks/query/use-websocket-events';
|
||||
|
||||
function YourComponent() {
|
||||
// This hook will process websocket events for React Query
|
||||
useWebsocketEvents();
|
||||
|
||||
// Rest of your component
|
||||
return (
|
||||
// ...
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Migrate Individual Slices
|
||||
|
||||
For each Redux slice you want to migrate:
|
||||
|
||||
1. Create a React Query hook that replaces the slice functionality
|
||||
2. Mark the slice as migrated
|
||||
3. Update components to use the new hook instead of Redux
|
||||
|
||||
Example for migrating the chat slice:
|
||||
|
||||
```tsx
|
||||
import { useChatMessages } from '#/hooks/query/use-chat-messages';
|
||||
import { getQueryReduxBridge } from '#/utils/query-redux-bridge';
|
||||
|
||||
// Mark the slice as migrated
|
||||
getQueryReduxBridge().migrateSlice('chat');
|
||||
|
||||
function ChatComponent() {
|
||||
// Instead of using useSelector and useDispatch
|
||||
// const messages = useSelector((state) => state.chat.messages);
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
// Use the React Query hook
|
||||
const {
|
||||
messages,
|
||||
addUserMessage,
|
||||
addAssistantMessage,
|
||||
addErrorMessage,
|
||||
clearMessages
|
||||
} = useChatMessages();
|
||||
|
||||
// Rest of your component using the new API
|
||||
return (
|
||||
// ...
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the Migration
|
||||
|
||||
To test the migration of a single slice:
|
||||
|
||||
1. Create the React Query hook for the slice
|
||||
2. Mark the slice as migrated using `getQueryReduxBridge().migrateSlice('sliceName')`
|
||||
3. Update a single component to use the new hook
|
||||
4. Test the application to ensure it works correctly
|
||||
5. If issues arise, you can easily revert by removing the migration flag
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Duplicate Updates
|
||||
|
||||
If you see duplicate updates (e.g., chat messages appearing twice), check:
|
||||
|
||||
1. Ensure you're using the bridge-aware WebSocket provider
|
||||
2. Verify the slice is properly marked as migrated
|
||||
3. Check that components aren't mixing Redux and React Query for the same slice
|
||||
|
||||
### Console Errors
|
||||
|
||||
If you encounter console errors:
|
||||
|
||||
1. Check for race conditions between Redux and React Query
|
||||
2. Ensure the WebSocket events hook is properly mounted
|
||||
3. Verify that the QueryReduxBridge is initialized before any components try to use it
|
||||
|
||||
## Complete Migration
|
||||
|
||||
Once all slices are migrated:
|
||||
|
||||
1. Remove the Redux store and related code
|
||||
2. Simplify the bridge code to remove Redux dependencies
|
||||
3. Update the WebSocket provider to directly update React Query without the bridge
|
||||
@@ -1,6 +1,28 @@
|
||||
import axios from "axios";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
export const openHands = axios.create();
|
||||
export const openHands = axios.create({
|
||||
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
|
||||
});
|
||||
|
||||
// Add response interceptor to handle 401 and 403 errors
|
||||
openHands.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Save the last page before redirecting
|
||||
saveLastPage();
|
||||
} else if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.tos_not_accepted
|
||||
) {
|
||||
// Save the last page before redirecting to TOS
|
||||
saveLastPage();
|
||||
window.location.href = "/tos";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const setAuthTokenHeader = (token: string) => {
|
||||
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
|
||||
@@ -346,6 +346,15 @@ class OpenHands {
|
||||
static async logout(): Promise<void> {
|
||||
await openHands.post("/api/logout");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the Terms of Service
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
static async acceptTOS(): Promise<boolean> {
|
||||
const response = await openHands.post("/api/accept_tos");
|
||||
return response.status === 200;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ContextMenuListItem({
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-tertiary rounded-md w-[140px]", className)}
|
||||
className={cn("bg-tertiary rounded-md", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "#/context/ws-client-provider";
|
||||
import { useNotification } from "#/hooks/useNotification";
|
||||
import { browserTab } from "#/utils/browser-tab";
|
||||
import { useStatusMessage } from "#/hooks/query/use-status-message";
|
||||
|
||||
const notificationStates = [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
@@ -22,7 +21,7 @@ const notificationStates = [
|
||||
export function AgentStatusBar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { statusMessage: curStatusMessage } = useStatusMessage();
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
const { status } = useWsClient();
|
||||
const { notify } = useNotification();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
@@ -10,7 +11,7 @@ import { EllipsisButton } from "./ellipsis-button";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
|
||||
import { useMetrics } from "#/hooks/query/use-metrics";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@@ -21,11 +22,14 @@ interface ConversationCardProps {
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
variant?: "compact" | "default";
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
}
|
||||
|
||||
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
@@ -34,7 +38,10 @@ export function ConversationCard({
|
||||
isActive,
|
||||
title,
|
||||
selectedRepository,
|
||||
// lastUpdatedAt is kept in props for backward compatibility
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
status = "STOPPED",
|
||||
variant = "default",
|
||||
conversationId,
|
||||
@@ -44,8 +51,8 @@ export function ConversationCard({
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Subscribe to metrics data from React Query
|
||||
const { metrics } = useMetrics();
|
||||
// Subscribe to metrics data from Redux store
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
@@ -104,11 +111,10 @@ export function ConversationCard({
|
||||
|
||||
if (data.vscode_url) {
|
||||
window.open(data.vscode_url, "_blank");
|
||||
} else {
|
||||
console.error("VS Code URL not available", data.error);
|
||||
}
|
||||
// VS Code URL not available
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch VS Code URL", error);
|
||||
// Failed to fetch VS Code URL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +133,12 @@ export function ConversationCard({
|
||||
}, [titleMode]);
|
||||
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || showDisplayCostOption);
|
||||
const timeBetweenUpdateAndCreation = createdAt
|
||||
? new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
|
||||
: 0;
|
||||
const showUpdateTime =
|
||||
createdAt &&
|
||||
timeBetweenUpdateAndCreation > MAX_TIME_BETWEEN_CREATION_AND_UPDATE;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -204,7 +216,16 @@ export function ConversationCard({
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
<span>Created </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))} ago
|
||||
</time>
|
||||
{showUpdateTime && (
|
||||
<>
|
||||
<span>, updated </span>
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
createdAt={project.created_at}
|
||||
status={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -5,23 +5,12 @@ interface ConversationRepoLinkProps {
|
||||
export function ConversationRepoLink({
|
||||
selectedRepository,
|
||||
}: ConversationRepoLinkProps) {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
window.open(
|
||||
`https://github.com/${selectedRepository}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
data-testid="conversation-card-selected-repository"
|
||||
onClick={handleClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
className="text-xs text-neutral-400"
|
||||
>
|
||||
{selectedRepository}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { NavLink, useLocation } from "react-router";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { UserActions } from "./user-actions";
|
||||
@@ -17,11 +16,11 @@ 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 { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
import { useAppLogout } from "#/hooks/use-app-logout";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
@@ -35,8 +34,6 @@ export function Sidebar() {
|
||||
isError: settingsIsError,
|
||||
isFetching: isFetchingSettings,
|
||||
} = useSettings();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
@@ -77,10 +74,12 @@ export function Sidebar() {
|
||||
endSession();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
const { handleLogout: appLogout } = useAppLogout();
|
||||
|
||||
const handleLogout = () => {
|
||||
// Save the current page before logout
|
||||
saveLastPage();
|
||||
appLogout();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import { saveLastPage } from "../utils/last-page";
|
||||
|
||||
interface AuthContextType {
|
||||
githubTokenIsSet: boolean;
|
||||
setGitHubTokenIsSet: (value: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
interface AuthContextProps extends React.PropsWithChildren {
|
||||
@@ -16,12 +18,21 @@ function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
|
||||
!!initialGithubTokenIsSet,
|
||||
);
|
||||
|
||||
const logout = React.useCallback(() => {
|
||||
setGitHubTokenIsSet(false);
|
||||
// Save the last page before logging out
|
||||
saveLastPage();
|
||||
// Clear any auth-related data from localStorage
|
||||
localStorage.removeItem("gh_token");
|
||||
}, [setGitHubTokenIsSet]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
githubTokenIsSet,
|
||||
setGitHubTokenIsSet,
|
||||
logout,
|
||||
}),
|
||||
[githubTokenIsSet, setGitHubTokenIsSet],
|
||||
[githubTokenIsSet, setGitHubTokenIsSet, logout],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -11,11 +11,11 @@ import { hydrateRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import "./i18n";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { initializeBridge, queryClient } from "./query-redux-bridge-init";
|
||||
import { queryClientConfig } from "./query-client-config";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
@@ -45,12 +45,9 @@ async function prepareApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// queryClient is now imported from query-redux-bridge-init.ts
|
||||
|
||||
prepareApp().then(() => {
|
||||
// Initialize the bridge and mark status slice as migrated
|
||||
initializeBridge();
|
||||
export const queryClient = new QueryClient(queryClientConfig);
|
||||
|
||||
prepareApp().then(() =>
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
@@ -65,5 +62,5 @@ prepareApp().then(() => {
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryReduxBridge } from "#/utils/query-redux-bridge";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Initial metrics state
|
||||
const initialMetrics: MetricsState = {
|
||||
cost: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access and manipulate metrics data using React Query
|
||||
* This replaces the Redux metrics slice functionality
|
||||
*/
|
||||
export function useMetrics() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Try to get the bridge, but don't throw if it's not initialized (for tests)
|
||||
let bridge: ReturnType<typeof getQueryReduxBridge> | null = null;
|
||||
try {
|
||||
bridge = getQueryReduxBridge();
|
||||
} catch (error) {
|
||||
// In tests, we might not have the bridge initialized
|
||||
console.warn("QueryReduxBridge not initialized, using default metrics");
|
||||
}
|
||||
|
||||
// Get initial state from Redux if this is the first time accessing the data
|
||||
const getInitialMetrics = (): MetricsState => {
|
||||
// If we already have data in React Query, use that
|
||||
const existingData = queryClient.getQueryData<MetricsState>(["metrics"]);
|
||||
if (existingData) return existingData;
|
||||
|
||||
// Otherwise, get initial data from Redux if bridge is available
|
||||
if (bridge) {
|
||||
try {
|
||||
return bridge.getReduxSliceState<MetricsState>("metrics");
|
||||
} catch (error) {
|
||||
// If we can't get the state from Redux, return the initial state
|
||||
return initialMetrics;
|
||||
}
|
||||
}
|
||||
|
||||
// If bridge is not available, return the initial state
|
||||
return initialMetrics;
|
||||
};
|
||||
|
||||
// Query for metrics
|
||||
const query = useQuery({
|
||||
queryKey: ["metrics"],
|
||||
queryFn: () => getInitialMetrics(),
|
||||
initialData: getInitialMetrics,
|
||||
staleTime: Infinity, // We manage updates manually through mutations
|
||||
});
|
||||
|
||||
// Mutation to set metrics
|
||||
const setMetricsMutation = useMutation({
|
||||
mutationFn: (metrics: MetricsState) => Promise.resolve(metrics),
|
||||
onMutate: async (metrics) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["metrics"],
|
||||
});
|
||||
|
||||
// Get current metrics
|
||||
const previousMetrics = queryClient.getQueryData<MetricsState>([
|
||||
"metrics",
|
||||
]);
|
||||
|
||||
// Update metrics
|
||||
queryClient.setQueryData(["metrics"], metrics);
|
||||
|
||||
return { previousMetrics };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
// Restore previous metrics on error
|
||||
if (context?.previousMetrics) {
|
||||
queryClient.setQueryData(["metrics"], context.previousMetrics);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
metrics: query.data || initialMetrics,
|
||||
isLoading: query.isLoading,
|
||||
setMetrics: setMetricsMutation.mutate,
|
||||
};
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryReduxBridge } from "#/utils/query-redux-bridge";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
// Initial status message
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access and manipulate status messages using React Query
|
||||
* This replaces the Redux status slice functionality
|
||||
*/
|
||||
export function useStatusMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Try to get the bridge, but don't throw if it's not initialized (for tests)
|
||||
let bridge: ReturnType<typeof getQueryReduxBridge> | null = null;
|
||||
try {
|
||||
bridge = getQueryReduxBridge();
|
||||
} catch (error) {
|
||||
// In tests, we might not have the bridge initialized
|
||||
console.warn(
|
||||
"QueryReduxBridge not initialized, using default status message",
|
||||
);
|
||||
}
|
||||
|
||||
// Get initial state from Redux if this is the first time accessing the data
|
||||
const getInitialStatusMessage = (): StatusMessage => {
|
||||
// If we already have data in React Query, use that
|
||||
const existingData = queryClient.getQueryData<StatusMessage>([
|
||||
"status",
|
||||
"currentMessage",
|
||||
]);
|
||||
if (existingData) return existingData;
|
||||
|
||||
// Otherwise, get initial data from Redux if bridge is available
|
||||
if (bridge) {
|
||||
try {
|
||||
return bridge.getReduxSliceState<{ curStatusMessage: StatusMessage }>(
|
||||
"status",
|
||||
).curStatusMessage;
|
||||
} catch (error) {
|
||||
// If we can't get the state from Redux, return the initial state
|
||||
return initialStatusMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// If bridge is not available, return the initial state
|
||||
return initialStatusMessage;
|
||||
};
|
||||
|
||||
// Query for status message
|
||||
const query = useQuery({
|
||||
queryKey: ["status", "currentMessage"],
|
||||
queryFn: () => getInitialStatusMessage(),
|
||||
initialData: getInitialStatusMessage,
|
||||
staleTime: Infinity, // We manage updates manually through mutations
|
||||
});
|
||||
|
||||
// Mutation to set current status message
|
||||
const setStatusMessageMutation = useMutation({
|
||||
mutationFn: (statusMessage: StatusMessage) =>
|
||||
Promise.resolve(statusMessage),
|
||||
onMutate: async (statusMessage) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["status", "currentMessage"],
|
||||
});
|
||||
|
||||
// Get current status message
|
||||
const previousStatusMessage = queryClient.getQueryData<StatusMessage>([
|
||||
"status",
|
||||
"currentMessage",
|
||||
]);
|
||||
|
||||
// Update status message
|
||||
queryClient.setQueryData(["status", "currentMessage"], statusMessage);
|
||||
|
||||
return { previousStatusMessage };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
// Restore previous status message on error
|
||||
if (context?.previousStatusMessage) {
|
||||
queryClient.setQueryData(
|
||||
["status", "currentMessage"],
|
||||
context.previousStatusMessage,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
statusMessage: query.data || initialStatusMessage,
|
||||
isLoading: query.isLoading,
|
||||
setStatusMessage: setStatusMessageMutation.mutate,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import { useLogout } from "./mutation/use-logout";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
import { useConfig } from "./query/use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppLogout = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else saveUserSettings({ unset_github_token: true });
|
||||
if (config?.APP_MODE === "saas") {
|
||||
navigate("/logout");
|
||||
} else {
|
||||
saveUserSettings({ unset_github_token: true });
|
||||
setGitHubTokenIsSet(false);
|
||||
localStorage.removeItem("gh_token");
|
||||
posthog.reset();
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return { handleLogout };
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
setUrl,
|
||||
} from "#/state/browser-slice";
|
||||
import { clearSelectedRepository } from "#/state/initial-query-slice";
|
||||
import { clearLastPage } from "#/utils/last-page";
|
||||
|
||||
export const useEndSession = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,6 +16,9 @@ export const useEndSession = () => {
|
||||
* End the current session by clearing the token and redirecting to the home page.
|
||||
*/
|
||||
const endSession = () => {
|
||||
// Clear the last page when starting a new project
|
||||
clearLastPage();
|
||||
|
||||
dispatch(clearSelectedRepository());
|
||||
|
||||
// Reset browser state to initial values
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import { getLastPage, clearLastPage } from "../utils/last-page";
|
||||
|
||||
export const usePostLoginRedirect = (isLoggedIn: boolean) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
// Only redirect to last page if user is on the root page
|
||||
if (location.pathname === "/") {
|
||||
const lastPage = getLastPage();
|
||||
if (lastPage) {
|
||||
navigate(lastPage);
|
||||
clearLastPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn, navigate, location.pathname]);
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
AUTH$TRY_AGAIN = "AUTH$TRY_AGAIN",
|
||||
AUTH$LOGOUT_ERROR = "AUTH$LOGOUT_ERROR",
|
||||
AUTH$LOGGING_OUT = "AUTH$LOGGING_OUT",
|
||||
AUTH$LOGGED_OUT = "AUTH$LOGGED_OUT",
|
||||
AUTH$LOG_IN_WITH_GITHUB = "AUTH$LOG_IN_WITH_GITHUB",
|
||||
APP$TITLE = "APP$TITLE",
|
||||
BROWSER$TITLE = "BROWSER$TITLE",
|
||||
BROWSER$EMPTY_MESSAGE = "BROWSER$EMPTY_MESSAGE",
|
||||
@@ -281,6 +286,7 @@ export enum I18nKey {
|
||||
ACTION_MESSAGE$EDIT = "ACTION_MESSAGE$EDIT",
|
||||
ACTION_MESSAGE$WRITE = "ACTION_MESSAGE$WRITE",
|
||||
ACTION_MESSAGE$BROWSE = "ACTION_MESSAGE$BROWSE",
|
||||
ACTION_MESSAGE$BROWSE_INTERACTIVE = "ACTION_MESSAGE$BROWSE_INTERACTIVE",
|
||||
ACTION_MESSAGE$THINK = "ACTION_MESSAGE$THINK",
|
||||
OBSERVATION_MESSAGE$RUN = "OBSERVATION_MESSAGE$RUN",
|
||||
OBSERVATION_MESSAGE$RUN_IPYTHON = "OBSERVATION_MESSAGE$RUN_IPYTHON",
|
||||
|
||||
@@ -1,4 +1,79 @@
|
||||
{
|
||||
"AUTH$TRY_AGAIN": {
|
||||
"en": "Try Again",
|
||||
"ja": "再試行",
|
||||
"zh-CN": "重试",
|
||||
"zh-TW": "重試",
|
||||
"ko-KR": "다시 시도",
|
||||
"no": "Prøv igjen",
|
||||
"it": "Riprova",
|
||||
"pt": "Tentar novamente",
|
||||
"es": "Intentar de nuevo",
|
||||
"ar": "حاول مرة أخرى",
|
||||
"fr": "Réessayer",
|
||||
"tr": "Tekrar dene",
|
||||
"de": "Erneut versuchen"
|
||||
},
|
||||
"AUTH$LOGOUT_ERROR": {
|
||||
"en": "An error occurred while logging out. Please try again.",
|
||||
"ja": "ログアウト中にエラーが発生しました。もう一度お試しください。",
|
||||
"zh-CN": "退出登录时发生错误。请重试。",
|
||||
"zh-TW": "登出時發生錯誤。請重試。",
|
||||
"ko-KR": "로그아웃 중 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
"no": "Det oppstod en feil under utlogging. Vennligst prøv igjen.",
|
||||
"it": "Si è verificato un errore durante la disconnessione. Per favore riprova.",
|
||||
"pt": "Ocorreu um erro ao sair. Por favor, tente novamente.",
|
||||
"es": "Ocurrió un error al cerrar sesión. Por favor, inténtelo de nuevo.",
|
||||
"ar": "حدث خطأ أثناء تسجيل الخروج. يرجى المحاولة مرة أخرى.",
|
||||
"fr": "Une erreur s'est produite lors de la déconnexion. Veuillez réessayer.",
|
||||
"tr": "Oturum kapatılırken bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
"de": "Beim Abmelden ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"AUTH$LOGGING_OUT": {
|
||||
"en": "Logging out...",
|
||||
"ja": "ログアウト中...",
|
||||
"zh-CN": "正在退出登录...",
|
||||
"zh-TW": "正在登出...",
|
||||
"ko-KR": "로그아웃 중...",
|
||||
"no": "Logger ut...",
|
||||
"it": "Disconnessione in corso...",
|
||||
"pt": "Saindo...",
|
||||
"es": "Cerrando sesión...",
|
||||
"ar": "جاري تسجيل الخروج...",
|
||||
"fr": "Déconnexion en cours...",
|
||||
"tr": "Oturum kapatılıyor...",
|
||||
"de": "Abmeldung läuft..."
|
||||
},
|
||||
"AUTH$LOGGED_OUT": {
|
||||
"en": "You have been logged out",
|
||||
"ja": "ログアウトしました",
|
||||
"zh-CN": "您已退出登录",
|
||||
"zh-TW": "您已登出",
|
||||
"ko-KR": "로그아웃되었습니다",
|
||||
"no": "Du har blitt logget ut",
|
||||
"it": "Sei stato disconnesso",
|
||||
"pt": "Você foi desconectado",
|
||||
"es": "Has cerrado sesión",
|
||||
"ar": "لقد تم تسجيل خروجك",
|
||||
"fr": "Vous avez été déconnecté",
|
||||
"tr": "Oturumunuz kapatıldı",
|
||||
"de": "Sie wurden abgemeldet"
|
||||
},
|
||||
"AUTH$LOG_IN_WITH_GITHUB": {
|
||||
"en": "Log in with GitHub",
|
||||
"ja": "GitHubでログイン",
|
||||
"zh-CN": "使用GitHub登录",
|
||||
"zh-TW": "使用GitHub登入",
|
||||
"ko-KR": "GitHub로 로그인",
|
||||
"no": "Logg inn med GitHub",
|
||||
"it": "Accedi con GitHub",
|
||||
"pt": "Entrar com GitHub",
|
||||
"es": "Iniciar sesión con GitHub",
|
||||
"ar": "تسجيل الدخول باستخدام GitHub",
|
||||
"fr": "Se connecter avec GitHub",
|
||||
"tr": "GitHub ile giriş yap",
|
||||
"de": "Mit GitHub anmelden"
|
||||
},
|
||||
"APP$TITLE": {
|
||||
"en": "App",
|
||||
"ja": "アプリ",
|
||||
@@ -4193,6 +4268,21 @@
|
||||
"es": "Navegando en la web",
|
||||
"tr": "Web'de geziniyor"
|
||||
},
|
||||
"ACTION_MESSAGE$BROWSE_INTERACTIVE": {
|
||||
"en": "Interactive browsing in progress...",
|
||||
"zh-CN": "交互式浏览进行中...",
|
||||
"zh-TW": "互動式瀏覽進行中...",
|
||||
"ko-KR": "인터랙티브 브라우징 진행 중...",
|
||||
"ja": "インタラクティブブラウジング進行中...",
|
||||
"no": "Interaktiv surfing pågår...",
|
||||
"ar": "التصفح التفاعلي قيد التقدم...",
|
||||
"de": "Interaktives Browsen läuft...",
|
||||
"fr": "Navigation interactive en cours...",
|
||||
"it": "Navigazione interattiva in corso...",
|
||||
"pt": "Navegação interativa em andamento...",
|
||||
"es": "Navegación interactiva en progreso...",
|
||||
"tr": "Etkileşimli tarama devam ediyor..."
|
||||
},
|
||||
"ACTION_MESSAGE$THINK": {
|
||||
"en": "Thinking",
|
||||
"zh-CN": "思考",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
initQueryReduxBridge,
|
||||
getQueryReduxBridge,
|
||||
SliceNames,
|
||||
} from "./utils/query-redux-bridge";
|
||||
import { queryClientConfig } from "./query-client-config";
|
||||
|
||||
// Create a query client
|
||||
export const queryClient = new QueryClient(queryClientConfig);
|
||||
|
||||
// Initialize the bridge
|
||||
export function initializeBridge() {
|
||||
// Initialize the bridge with the query client
|
||||
initQueryReduxBridge(queryClient);
|
||||
|
||||
// Mark slices as migrated to React Query
|
||||
getQueryReduxBridge().migrateSlice("status");
|
||||
getQueryReduxBridge().migrateSlice("metrics");
|
||||
}
|
||||
|
||||
// Export a function to check if a slice is migrated
|
||||
export function isSliceMigrated(sliceName: SliceNames) {
|
||||
try {
|
||||
return getQueryReduxBridge().isSliceMigrated(sliceName);
|
||||
} catch (error) {
|
||||
// If the bridge is not initialized, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
} from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
layout("routes/_no-sidebar/route.tsx", [
|
||||
route("logout", "routes/logout.tsx"),
|
||||
route("tos", "routes/tos.tsx"),
|
||||
]),
|
||||
layout("routes/_oh/route.tsx", [
|
||||
index("routes/_oh._index/route.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function NoSidebarLayout() {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,20 +78,18 @@ function FileViewer() {
|
||||
<div className="flex h-full bg-base-secondary relative">
|
||||
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{selectedPath && (
|
||||
<div className="flex w-full items-center justify-between self-end p-2">
|
||||
<span className="text-sm text-neutral-500">{selectedPath}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedPath && files[selectedPath] && (
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<SyntaxHighlighter
|
||||
language={getLanguageFromPath(selectedPath)}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "10px",
|
||||
height: "100%",
|
||||
background: "#171717",
|
||||
fontSize: "0.875rem",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{files[selectedPath]}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { useEndSession } from "../../../hooks/use-end-session";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@@ -32,6 +33,8 @@ export const useHandleWSEvents = () => {
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
// Save the last page before ending session
|
||||
saveLastPage();
|
||||
displayErrorToast("Session expired.");
|
||||
endSession();
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { usePostLoginRedirect } from "#/hooks/use-post-login-redirect";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -112,8 +114,41 @@ export default function MainApp() {
|
||||
}, [error?.status, pathname, isFetching]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
const isOnLogoutPage = pathname === "/logout";
|
||||
|
||||
// In SaaS mode, when not authenticated and not on logout page, redirect to GitHub auth
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!isFetchingAuth &&
|
||||
!userIsAuthed &&
|
||||
config.data?.APP_MODE === "saas" &&
|
||||
!isOnLogoutPage &&
|
||||
gitHubAuthUrl
|
||||
) {
|
||||
window.location.href = gitHubAuthUrl;
|
||||
}
|
||||
}, [
|
||||
isFetchingAuth,
|
||||
userIsAuthed,
|
||||
config.data?.APP_MODE,
|
||||
isOnLogoutPage,
|
||||
gitHubAuthUrl,
|
||||
]);
|
||||
|
||||
// Only show waitlist modal in non-SaaS mode
|
||||
const renderWaitlistModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE !== "saas";
|
||||
|
||||
// Handle redirection to last page after login
|
||||
usePostLoginRedirect(userIsAuthed);
|
||||
|
||||
// Track page visits for last page functionality
|
||||
React.useEffect(() => {
|
||||
if (pathname && userIsAuthed) {
|
||||
// Save the current page for future reference
|
||||
saveLastPage();
|
||||
}
|
||||
}, [pathname, userIsAuthed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect, useSearchParams } from "react-router";
|
||||
import React from "react";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { queryClient } from "#/query-redux-bridge-init";
|
||||
import { queryClient } from "#/entry.client";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Hardcoded translations since we don't want to load i18n
|
||||
const translations = {
|
||||
LOGGING_OUT: "Logging out...",
|
||||
LOGGED_OUT: "You have been logged out",
|
||||
LOG_IN_WITH_GITHUB: "Log in with GitHub",
|
||||
LOGOUT_ERROR: "An error occurred while logging out. Please try again.",
|
||||
TRY_AGAIN: "Try Again",
|
||||
};
|
||||
|
||||
export default function LogoutPage() {
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(true);
|
||||
const [hasLogoutError, setHasLogoutError] = React.useState(false);
|
||||
const hasAttemptedLogout = React.useRef(false);
|
||||
|
||||
// Generate GitHub auth URL once on mount
|
||||
const gitHubAuthUrl = React.useMemo(
|
||||
() => generateGitHubAuthUrl("github", new URL(window.location.href)),
|
||||
[],
|
||||
);
|
||||
|
||||
const performLogout = React.useCallback(async () => {
|
||||
// Only attempt logout once
|
||||
if (hasAttemptedLogout.current) return;
|
||||
hasAttemptedLogout.current = true;
|
||||
|
||||
try {
|
||||
// Use the OpenHands API client for consistent headers and error handling
|
||||
await OpenHands.logout();
|
||||
|
||||
// Clear any auth-related data from localStorage
|
||||
localStorage.removeItem("gh_token");
|
||||
|
||||
setIsLoggingOut(false);
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
setHasLogoutError(true);
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
performLogout();
|
||||
}, [performLogout]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-base">
|
||||
<div className="flex flex-col items-center gap-8 p-8 rounded-lg bg-neutral-800">
|
||||
<AllHandsLogoButton
|
||||
onClick={() => {
|
||||
if (gitHubAuthUrl) {
|
||||
window.location.href = gitHubAuthUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isLoggingOut ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="large" />
|
||||
<span className="text-neutral-200">{translations.LOGGING_OUT}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-neutral-200">
|
||||
{hasLogoutError
|
||||
? translations.LOGOUT_ERROR
|
||||
: translations.LOGGED_OUT}
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4">
|
||||
{hasLogoutError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={performLogout}
|
||||
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 text-center"
|
||||
>
|
||||
{translations.TRY_AGAIN}
|
||||
</button>
|
||||
)}
|
||||
{!hasLogoutError && gitHubAuthUrl && (
|
||||
<a
|
||||
href={gitHubAuthUrl}
|
||||
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 text-center"
|
||||
>
|
||||
{translations.LOG_IN_WITH_GITHUB}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCallback } from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
|
||||
export default function TOSPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAcceptTOS = useCallback(async () => {
|
||||
try {
|
||||
const success = await OpenHands.acceptTOS();
|
||||
if (success) {
|
||||
// Get the last page from localStorage or default to root
|
||||
const lastPage = localStorage.getItem("openhands_last_page") || "/";
|
||||
navigate(lastPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to accept TOS:", error);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<div className="max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Terms of Service</h1>
|
||||
|
||||
<div className="prose dark:prose-invert mb-8">
|
||||
<p>
|
||||
Welcome to OpenHands. To continue using our service, you must read
|
||||
and accept our Terms of Service.
|
||||
</p>
|
||||
|
||||
<div className="my-6">
|
||||
<p className="mb-4">
|
||||
Please review our complete Terms of Service at:
|
||||
</p>
|
||||
<a
|
||||
href="https://www.all-hands.dev/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 underline"
|
||||
>
|
||||
https://www.all-hands.dev/tos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-6">
|
||||
By clicking "Accept Terms of Service" below, you
|
||||
acknowledge that you have read, understood, and agree to be bound by
|
||||
the Terms of Service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleAcceptTOS}
|
||||
>
|
||||
Accept Terms of Service
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import { trackError } from "#/utils/error-handler";
|
||||
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
|
||||
import { setCode, setActiveFilepath } from "#/state/code-slice";
|
||||
import { appendJupyterInput } from "#/state/jupyter-slice";
|
||||
// Status and metrics slices are now handled by React Query
|
||||
import { setCurStatusMessage } from "#/state/status-slice";
|
||||
import { setMetrics } from "#/state/metrics-slice";
|
||||
import store from "#/store";
|
||||
import { getQueryReduxBridge } from "#/utils/query-redux-bridge";
|
||||
import ActionType from "#/types/action-type";
|
||||
import {
|
||||
ActionMessage,
|
||||
@@ -95,21 +95,7 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
usage: message.tool_call_metadata?.model_response?.usage ?? null,
|
||||
};
|
||||
try {
|
||||
const bridge = getQueryReduxBridge();
|
||||
if (bridge.isSliceMigrated("metrics")) {
|
||||
// If metrics slice is migrated, update React Query directly
|
||||
bridge.syncReduxToQuery(["metrics"], metrics);
|
||||
} else {
|
||||
// Otherwise, dispatch to Redux (handled by the bridge)
|
||||
bridge.conditionalDispatch("metrics", {
|
||||
type: "metrics/setMetrics",
|
||||
payload: metrics,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to update metrics:", error);
|
||||
}
|
||||
store.dispatch(setMetrics(metrics));
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN) {
|
||||
@@ -138,8 +124,11 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
|
||||
export function handleStatusMessage(message: StatusMessage) {
|
||||
if (message.type === "info") {
|
||||
// Status slice is now handled by React Query
|
||||
// The websocket events hook will update the React Query cache
|
||||
store.dispatch(
|
||||
setCurStatusMessage({
|
||||
...message,
|
||||
}),
|
||||
);
|
||||
} else if (message.type === "error") {
|
||||
trackError({
|
||||
message: message.message,
|
||||
|
||||
@@ -30,6 +30,7 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
store.dispatch(appendJupyterOutput(message.content));
|
||||
break;
|
||||
case ObservationType.BROWSE:
|
||||
case ObservationType.BROWSE_INTERACTIVE:
|
||||
if (message.extras?.screenshot) {
|
||||
store.dispatch(setScreenshotSrc(message.extras?.screenshot));
|
||||
}
|
||||
@@ -178,6 +179,46 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "browse_interactive":
|
||||
store.dispatch(
|
||||
addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "browse_interactive" as const,
|
||||
extras: {
|
||||
url: String(message.extras.url || ""),
|
||||
screenshot: String(message.extras.screenshot || ""),
|
||||
error: Boolean(message.extras.error),
|
||||
open_page_urls: Array.isArray(message.extras.open_page_urls)
|
||||
? message.extras.open_page_urls
|
||||
: [],
|
||||
active_page_index: Number(message.extras.active_page_index || 0),
|
||||
dom_object:
|
||||
typeof message.extras.dom_object === "object"
|
||||
? (message.extras.dom_object as Record<string, unknown>)
|
||||
: {},
|
||||
axtree_object:
|
||||
typeof message.extras.axtree_object === "object"
|
||||
? (message.extras.axtree_object as Record<string, unknown>)
|
||||
: {},
|
||||
extra_element_properties:
|
||||
typeof message.extras.extra_element_properties === "object"
|
||||
? (message.extras.extra_element_properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {},
|
||||
last_browser_action: String(
|
||||
message.extras.last_browser_action || "",
|
||||
),
|
||||
last_browser_action_error:
|
||||
message.extras.last_browser_action_error,
|
||||
focused_element_bid: String(
|
||||
message.extras.focused_element_bid || "",
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
store.dispatch(
|
||||
addAssistantObservation({
|
||||
|
||||
@@ -20,6 +20,7 @@ const HANDLED_ACTIONS: OpenHandsEventType[] = [
|
||||
"write",
|
||||
"read",
|
||||
"browse",
|
||||
"browse_interactive",
|
||||
"edit",
|
||||
];
|
||||
|
||||
@@ -108,6 +109,9 @@ export const chatSlice = createSlice({
|
||||
text = `${action.payload.args.path}\n${content}`;
|
||||
} else if (actionID === "browse") {
|
||||
text = `Browsing ${action.payload.args.url}`;
|
||||
} else if (actionID === "browse_interactive") {
|
||||
// Include the browser_actions in the content
|
||||
text = `**Action:**\n\n\`\`\`python\n${action.payload.args.browser_actions}\n\`\`\``;
|
||||
}
|
||||
if (actionID === "run" || actionID === "run_ipython") {
|
||||
if (
|
||||
@@ -127,6 +131,7 @@ export const chatSlice = createSlice({
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
state.messages.push(message);
|
||||
},
|
||||
|
||||
@@ -191,11 +196,11 @@ export const chatSlice = createSlice({
|
||||
} else if (observationID === "browse") {
|
||||
let content = `**URL:** ${observation.payload.extras.url}\n`;
|
||||
if (observation.payload.extras.error) {
|
||||
content += `**Error:**\n${observation.payload.extras.error}\n`;
|
||||
content += `\n\n**Error:**\n${observation.payload.extras.error}\n`;
|
||||
}
|
||||
content += `**Output:**\n${observation.payload.content}`;
|
||||
content += `\n\n**Output:**\n${observation.payload.content}`;
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
causeMessage.content = content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const initialState: MetricsState = {
|
||||
cost: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
const metricsSlice = createSlice({
|
||||
name: "metrics",
|
||||
initialState,
|
||||
reducers: {
|
||||
setMetrics: (state, action: PayloadAction<MetricsState>) => {
|
||||
state.cost = action.payload.cost;
|
||||
state.usage = action.payload.usage;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMetrics } = metricsSlice.actions;
|
||||
export default metricsSlice.reducer;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export const statusSlice = createSlice({
|
||||
name: "status",
|
||||
initialState: {
|
||||
curStatusMessage: initialStatusMessage,
|
||||
},
|
||||
reducers: {
|
||||
setCurStatusMessage: (state, action: PayloadAction<StatusMessage>) => {
|
||||
state.curStatusMessage = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCurStatusMessage } = statusSlice.actions;
|
||||
|
||||
export default statusSlice.reducer;
|
||||
@@ -8,7 +8,8 @@ import initialQueryReducer from "./state/initial-query-slice";
|
||||
import commandReducer from "./state/command-slice";
|
||||
import { jupyterReducer } from "./state/jupyter-slice";
|
||||
import securityAnalyzerReducer from "./state/security-analyzer-slice";
|
||||
// Status and metrics slices are now handled by React Query
|
||||
import statusReducer from "./state/status-slice";
|
||||
import metricsReducer from "./state/metrics-slice";
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
fileState: fileStateReducer,
|
||||
@@ -20,7 +21,8 @@ export const rootReducer = combineReducers({
|
||||
agent: agentReducer,
|
||||
jupyter: jupyterReducer,
|
||||
securityAnalyzer: securityAnalyzerReducer,
|
||||
// status and metrics slices removed (migrated to React Query)
|
||||
status: statusReducer,
|
||||
metrics: metricsReducer,
|
||||
});
|
||||
|
||||
const store = configureStore({
|
||||
|
||||
@@ -51,6 +51,24 @@ export interface BrowseObservation extends OpenHandsObservationEvent<"browse"> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrowseInteractiveObservation
|
||||
extends OpenHandsObservationEvent<"browse_interactive"> {
|
||||
source: "agent";
|
||||
extras: {
|
||||
url: string;
|
||||
screenshot: string;
|
||||
error: boolean;
|
||||
open_page_urls: string[];
|
||||
active_page_index: number;
|
||||
dom_object: Record<string, unknown>;
|
||||
axtree_object: Record<string, unknown>;
|
||||
extra_element_properties: Record<string, unknown>;
|
||||
last_browser_action: string;
|
||||
last_browser_action_error: unknown;
|
||||
focused_element_bid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WriteObservation extends OpenHandsObservationEvent<"write"> {
|
||||
source: "agent";
|
||||
extras: {
|
||||
@@ -98,6 +116,7 @@ export type OpenHandsObservation =
|
||||
| IPythonObservation
|
||||
| DelegateObservation
|
||||
| BrowseObservation
|
||||
| BrowseInteractiveObservation
|
||||
| WriteObservation
|
||||
| ReadObservation
|
||||
| EditObservation
|
||||
|
||||
@@ -8,6 +8,9 @@ enum ObservationType {
|
||||
// The HTML contents of a URL
|
||||
BROWSE = "browse",
|
||||
|
||||
// Interactive browsing
|
||||
BROWSE_INTERACTIVE = "browse_interactive",
|
||||
|
||||
// The output of a command
|
||||
RUN = "run",
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Formats a date into a human-readable string representing the time delta between the given date and the current date.
|
||||
* Formats a date into a compact string representing the time delta between the given date and the current date.
|
||||
* @param date The date to format
|
||||
* @returns A human-readable string representing the time delta between the given date and the current date
|
||||
* @returns A compact string representing the time delta between the given date and the current date
|
||||
*
|
||||
* @example
|
||||
* // now is 2024-01-01T00:00:00Z
|
||||
* formatTimeDelta(new Date("2023-12-31T23:59:59Z")); // "1 second"
|
||||
* formatTimeDelta(new Date("2022-01-01T00:00:00Z")); // "2 years"
|
||||
* formatTimeDelta(new Date("2023-12-31T23:59:59Z")); // "1s"
|
||||
* formatTimeDelta(new Date("2022-01-01T00:00:00Z")); // "2y"
|
||||
*/
|
||||
export const formatTimeDelta = (date: Date) => {
|
||||
const now = new Date();
|
||||
@@ -19,11 +19,10 @@ export const formatTimeDelta = (date: Date) => {
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(months / 12);
|
||||
|
||||
if (seconds < 60) return seconds === 1 ? "1 second" : `${seconds} seconds`;
|
||||
if (minutes < 60) return minutes === 1 ? "1 minute" : `${minutes} minutes`;
|
||||
if (hours < 24) return hours === 1 ? "1 hour" : `${hours} hours`;
|
||||
if (days < 30) return days === 1 ? "1 day" : `${days} days`;
|
||||
if (months < 12) return months === 1 ? "1 month" : `${months} months`;
|
||||
|
||||
return years === 1 ? "1 year" : `${years} years`;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
if (days < 30) return `${days}d`;
|
||||
if (months < 12) return `${months}mo`;
|
||||
return `${years}y`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const LAST_PAGE_KEY = "openhands_last_page";
|
||||
|
||||
export const saveLastPage = () => {
|
||||
const currentPath = window.location.pathname;
|
||||
// Don't save root, logout, tos, or settings pages
|
||||
if (
|
||||
!currentPath.includes("/settings") &&
|
||||
currentPath !== "/" &&
|
||||
currentPath !== "/logout" &&
|
||||
currentPath !== "/tos"
|
||||
) {
|
||||
localStorage.setItem(LAST_PAGE_KEY, currentPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLastPage = (): string | null =>
|
||||
localStorage.getItem(LAST_PAGE_KEY);
|
||||
|
||||
export const clearLastPage = () => {
|
||||
localStorage.removeItem(LAST_PAGE_KEY);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import store from "#/store";
|
||||
|
||||
// Feature flags to control which slices are migrated to React Query
|
||||
export type SliceNames =
|
||||
| "chat"
|
||||
| "agent"
|
||||
| "browser"
|
||||
| "code"
|
||||
| "command"
|
||||
| "fileState"
|
||||
| "initialQuery"
|
||||
| "jupyter"
|
||||
| "securityAnalyzer"
|
||||
| "status"
|
||||
| "metrics";
|
||||
|
||||
// Track which slices have been migrated to React Query
|
||||
const migratedSlices: Record<SliceNames, boolean> = {
|
||||
chat: false,
|
||||
agent: false,
|
||||
browser: false,
|
||||
code: false,
|
||||
command: false,
|
||||
fileState: false,
|
||||
initialQuery: false,
|
||||
jupyter: false,
|
||||
securityAnalyzer: false,
|
||||
status: false,
|
||||
metrics: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* QueryReduxBridge provides utilities to help migrate from Redux to React Query
|
||||
* while maintaining compatibility with existing code.
|
||||
*/
|
||||
export class QueryReduxBridge {
|
||||
private queryClient: QueryClient;
|
||||
|
||||
constructor(queryClient: QueryClient) {
|
||||
this.queryClient = queryClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a slice as migrated to React Query
|
||||
*/
|
||||
// Using this.queryClient to satisfy class-methods-use-this rule
|
||||
migrateSlice(sliceName: SliceNames): void {
|
||||
migratedSlices[sliceName] = true;
|
||||
// Access this.queryClient to use 'this'
|
||||
this.queryClient.getQueryCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a slice has been migrated to React Query
|
||||
*/
|
||||
// Using this.queryClient to satisfy class-methods-use-this rule
|
||||
isSliceMigrated(sliceName: SliceNames): boolean {
|
||||
// Access this.queryClient to use 'this'
|
||||
this.queryClient.getQueryCache();
|
||||
return migratedSlices[sliceName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a slice from Redux
|
||||
*/
|
||||
// Using this.queryClient to satisfy class-methods-use-this rule
|
||||
getReduxSliceState<T>(sliceName: SliceNames): T {
|
||||
// Access this.queryClient to use 'this'
|
||||
this.queryClient.getQueryCache();
|
||||
// Using type assertion to handle the dynamic slice name
|
||||
const state = store.getState();
|
||||
return state[sliceName as keyof typeof state] as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update React Query data for a migrated slice
|
||||
* This should be called when Redux state changes and we want to sync to React Query
|
||||
*/
|
||||
syncReduxToQuery<T>(queryKey: unknown[], data: T): void {
|
||||
this.queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a Redux action only if the slice hasn't been migrated
|
||||
* This prevents duplicate updates when a slice is migrated
|
||||
*/
|
||||
conditionalDispatch(
|
||||
sliceName: SliceNames,
|
||||
action: { type: string; payload?: unknown },
|
||||
): void {
|
||||
if (!this.isSliceMigrated(sliceName)) {
|
||||
store.dispatch(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a React Query mutation that also updates Redux if needed
|
||||
* This helps maintain backward compatibility during migration
|
||||
*/
|
||||
createHybridMutation<TData, TVariables>(
|
||||
sliceName: SliceNames,
|
||||
mutationFn: (variables: TVariables) => Promise<TData>,
|
||||
reduxAction: (data: TData) => { type: string; payload?: unknown },
|
||||
) {
|
||||
return {
|
||||
mutationFn,
|
||||
onSuccess: (data: TData) => {
|
||||
// If the slice is still using Redux, dispatch the action
|
||||
if (!this.isSliceMigrated(sliceName)) {
|
||||
store.dispatch(reduxAction(data));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
let queryReduxBridge: QueryReduxBridge | null = null;
|
||||
|
||||
export function initQueryReduxBridge(
|
||||
queryClient: QueryClient,
|
||||
): QueryReduxBridge {
|
||||
queryReduxBridge = new QueryReduxBridge(queryClient);
|
||||
return queryReduxBridge;
|
||||
}
|
||||
|
||||
export function getQueryReduxBridge(): QueryReduxBridge {
|
||||
if (!queryReduxBridge) {
|
||||
throw new Error(
|
||||
"QueryReduxBridge not initialized. Call initQueryReduxBridge first.",
|
||||
);
|
||||
}
|
||||
return queryReduxBridge;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
<VERSION_CONTROL>
|
||||
* 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.
|
||||
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
|
||||
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
|
||||
</VERSION_CONTROL>
|
||||
|
||||
<PROBLEM_SOLVING_WORKFLOW>
|
||||
|
||||
@@ -122,6 +122,8 @@ def initialize_repository_for_runtime(
|
||||
selected_repository,
|
||||
None,
|
||||
)
|
||||
# Run setup script if it exists
|
||||
runtime.maybe_run_setup_script()
|
||||
|
||||
return repo_directory
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ class ActionExecutor:
|
||||
|
||||
await wait_all(
|
||||
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
|
||||
timeout=30,
|
||||
timeout=60,
|
||||
)
|
||||
logger.debug('All plugins initialized')
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class ActionExecutionClient(Runtime):
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
self.session = HttpSession()
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from types import MappingProxyType
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
@@ -16,4 +15,4 @@ class ConversationInitData(Settings):
|
||||
|
||||
model_config = {
|
||||
'arbitrary_types_allowed': True,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ from openhands.events.observation import (
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.server.session.agent_session import AgentSession
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
@@ -35,6 +35,7 @@ class Settings(BaseModel):
|
||||
user_consents_to_analytics: bool | None = None
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
accepted_tos: bool = False
|
||||
|
||||
model_config = {
|
||||
'validate_assignment': True,
|
||||
|
||||
Generated
+16
-20
@@ -4943,18 +4943,20 @@ realtime = ["websockets (>=13,<15)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "^3.12"
|
||||
python-versions = "<4.0,>=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_aci-0.2.7-py3-none-any.whl", hash = "sha256:6b36fa465db6643d909efdf40ec303d27a03e6c9f568447df4bc1d9fdd7104b2"},
|
||||
{file = "openhands_aci-0.2.7.tar.gz", hash = "sha256:892c33d741e94b78ec65df178afe018869e6039ea484f7f232ee8c1bb4e440ef"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
binaryornot = "^0.4.4"
|
||||
cachetools = "^5.5.2"
|
||||
chardet = "^5.0.0"
|
||||
binaryornot = ">=0.4.4,<0.5.0"
|
||||
cachetools = ">=5.5.2,<6.0.0"
|
||||
charset-normalizer = ">=3.4.1,<4.0.0"
|
||||
flake8 = "*"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
@@ -4963,18 +4965,12 @@ networkx = "*"
|
||||
numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "^0.24.0"
|
||||
tree-sitter-javascript = "^0.23.1"
|
||||
tree-sitter-python = "^0.23.6"
|
||||
tree-sitter-ruby = "^0.23.1"
|
||||
tree-sitter-typescript = "^0.23.2"
|
||||
whatthepatch = "^1.0.6"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/openhands-aci.git"
|
||||
reference = "add-encoding-detection"
|
||||
resolved_reference = "040d9578d90894409f51ecca877b120fe696fe0b"
|
||||
tree-sitter = ">=0.24.0,<0.25.0"
|
||||
tree-sitter-javascript = ">=0.23.1,<0.24.0"
|
||||
tree-sitter-python = ">=0.23.6,<0.24.0"
|
||||
tree-sitter-ruby = ">=0.23.1,<0.24.0"
|
||||
tree-sitter-typescript = ">=0.23.2,<0.24.0"
|
||||
whatthepatch = ">=1.0.6,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
@@ -9316,4 +9312,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d3ec6b8a6c7e48420d76b7e17d5f1a3f253fa603205f90d4a8e4a614ab5e2c67"
|
||||
content-hash = "7c5c7d26747d7b42a1a7bbdc3b7e4d87bbbef851f3c51a022bf7a15082273e5a"
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ runloop-api-client = "0.26.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
openhands-aci = "^0.2.6"
|
||||
openhands-aci = "^0.2.7"
|
||||
python-socketio = "^5.11.4"
|
||||
redis = "^5.2.0"
|
||||
sse-starlette = "^2.1.3"
|
||||
|
||||
Reference in New Issue
Block a user