mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Merge branch 'main' into rb/fix-client-loader
This commit is contained in:
commit
dad3c0c0be
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@ -11,5 +11,5 @@ jobs:
|
||||
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
|
||||
if: github.event.label.name == 'fix-me'
|
||||
with:
|
||||
issue_number: ${{ github.event.issue.number }}
|
||||
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
secrets: inherit
|
||||
|
||||
@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
### 9. Use existing Docker image
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.9-nikolaik
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@ -58,4 +58,3 @@ The main interface consists of several key components:
|
||||
3. Use one of the recommended models, as described in the [LLMs section](usage/llms/llms.md).
|
||||
|
||||
Remember, the GUI mode of OpenHands is designed to make your interaction with the AI assistant as smooth and intuitive as possible. Don't hesitate to explore its features to maximize your productivity.
|
||||
|
||||
|
||||
2715
docs/package-lock.json
generated
2715
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.5.2",
|
||||
"@docusaurus/plugin-content-pages": "^3.5.2",
|
||||
"@docusaurus/preset-classic": "^3.5.2",
|
||||
"@docusaurus/theme-mermaid": "^3.5.2",
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.0",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/theme-mermaid": "^3.6.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.5.2",
|
||||
"@docusaurus/tsconfig": "^3.6.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
|
||||
3407
docs/yarn.lock
3407
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -81,7 +81,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
|
||||
'</pr_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
|
||||
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
|
||||
'Your task is to make the minimal changes to non-tests files in the /repo directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Follow these steps to resolve the issue:\n'
|
||||
'1. As a first step, it might be a good idea to explore the repo to familiarize yourself with its structure.\n'
|
||||
'2. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import * as ChatSlice from "#/state/chatSlice";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
|
||||
render(<ChatInterface />, { wrapper: SocketProvider });
|
||||
renderWithProviders(<ChatInterface />);
|
||||
|
||||
describe("Empty state", () => {
|
||||
const { send: sendMock } = vi.hoisted(() => ({
|
||||
send: vi.fn(),
|
||||
}));
|
||||
|
||||
const { useSocket: useSocketMock } = vi.hoisted(() => ({
|
||||
useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("#/context/socket", async (importActual) => ({
|
||||
...(await importActual<typeof import("#/context/socket")>()),
|
||||
useSocket: useSocketMock,
|
||||
}));
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.todo("should render suggestions if empty");
|
||||
it("should render suggestions if empty", () => {
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(
|
||||
addUserMessage({
|
||||
content: "Hello",
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the default suggestions", () => {
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
|
||||
|
||||
// check that there are at most 4 suggestions displayed
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
|
||||
|
||||
// Check that each displayed suggestion is one of the repo suggestions
|
||||
displayedSuggestions.forEach((suggestion) => {
|
||||
expect(repoSuggestions).toContain(suggestion.textContent);
|
||||
});
|
||||
});
|
||||
|
||||
it.fails(
|
||||
"should load the a user message to the input when selecting",
|
||||
async () => {
|
||||
// this is to test that the message is in the UI before the socket is called
|
||||
useSocketMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
|
||||
const user = userEvent.setup();
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
const input = screen.getByTestId("chat-input");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
|
||||
// user message loaded to input
|
||||
expect(addUserMessageSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
|
||||
expect(store.getState().chat.messages).toHaveLength(0);
|
||||
expect(input).toHaveValue(displayedSuggestions[0].textContent);
|
||||
},
|
||||
);
|
||||
|
||||
it.fails(
|
||||
"should send the message to the socket only if the runtime is active",
|
||||
async () => {
|
||||
useSocketMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
|
||||
useSocketMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: true, // mock an active runtime setup
|
||||
}));
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
beforeAll(() => {
|
||||
// mock useScrollToBottom hook
|
||||
vi.mock("#/hooks/useScrollToBottom", () => ({
|
||||
useScrollToBottom: vi.fn(() => ({
|
||||
scrollDomToBottom: vi.fn(),
|
||||
onChatBodyScroll: vi.fn(),
|
||||
hitBottom: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render messages", () => {
|
||||
const messages: Message[] = [
|
||||
|
||||
@ -25,6 +25,21 @@ describe("InteractiveChatBox", () => {
|
||||
within(chatBox).getByTestId("upload-image-input");
|
||||
});
|
||||
|
||||
it.fails("should set custom values", () => {
|
||||
render(
|
||||
<InteractiveChatBox
|
||||
onSubmit={onSubmitMock}
|
||||
onStop={onStopMock}
|
||||
value="Hello, world!"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
const chatInput = within(chatBox).getByTestId("chat-input");
|
||||
|
||||
expect(chatInput).toHaveValue("Hello, world!");
|
||||
});
|
||||
|
||||
it("should display the image previews when images are uploaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
|
||||
|
||||
30
frontend/__tests__/components/suggestion-item.test.tsx
Normal file
30
frontend/__tests__/components/suggestion-item.test.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SuggestionItem } from "#/components/suggestion-item";
|
||||
|
||||
describe("SuggestionItem", () => {
|
||||
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
||||
const onClick = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render a suggestion", () => {
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
expect(screen.getByTestId("suggestion")).toBeInTheDocument();
|
||||
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
const suggestion = screen.getByTestId("suggestion");
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith("a long text value");
|
||||
});
|
||||
});
|
||||
60
frontend/__tests__/components/suggestions.test.tsx
Normal file
60
frontend/__tests__/components/suggestions.test.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Suggestions } from "#/components/suggestions";
|
||||
|
||||
describe("Suggestions", () => {
|
||||
const firstSuggestion = {
|
||||
label: "first-suggestion",
|
||||
value: "value-of-first-suggestion",
|
||||
};
|
||||
const secondSuggestion = {
|
||||
label: "second-suggestion",
|
||||
value: "value-of-second-suggestion",
|
||||
};
|
||||
const suggestions = [firstSuggestion, secondSuggestion];
|
||||
|
||||
const onSuggestionClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render suggestions", () => {
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
expect(suggestionElements).toHaveLength(2);
|
||||
expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
|
||||
expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
|
||||
});
|
||||
|
||||
it("should call onSuggestionClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
await user.click(suggestionElements[0]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-first-suggestion",
|
||||
);
|
||||
|
||||
await user.click(suggestionElements[1]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-second-suggestion",
|
||||
);
|
||||
});
|
||||
});
|
||||
17
frontend/__tests__/initial-query.test.tsx
Normal file
17
frontend/__tests__/initial-query.test.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import { setInitialQuery, clearInitialQuery } from "../src/state/initial-query-slice";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
it("should clear initial query when clearInitialQuery is dispatched", () => {
|
||||
// Set up initial query in the store
|
||||
store.dispatch(setInitialQuery("test query"));
|
||||
expect(store.getState().initalQuery.initialQuery).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
store.dispatch(clearInitialQuery());
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(store.getState().initalQuery.initialQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
5
frontend/__tests__/routes/_oh.app.test.tsx
Normal file
5
frontend/__tests__/routes/_oh.app.test.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("App", () => {
|
||||
it.todo("should render");
|
||||
});
|
||||
74
frontend/__tests__/utils/cache.test.ts
Normal file
74
frontend/__tests__/utils/cache.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { afterEach } from "node:test";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cache } from "#/utils/cache";
|
||||
|
||||
describe("Cache", () => {
|
||||
const testKey = "key";
|
||||
const testData = { message: "Hello, world!" };
|
||||
const testTTL = 1000; // 1 second
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets data in localStorage with expiration", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
const cachedEntry = JSON.parse(
|
||||
localStorage.getItem(`app_cache_${testKey}`) || "",
|
||||
);
|
||||
|
||||
expect(cachedEntry.data).toEqual(testData);
|
||||
expect(cachedEntry.expiration).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("gets data from localStorage if not expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
expect(cache.get(testKey)).toEqual(testData);
|
||||
});
|
||||
|
||||
it("should expire after 5 minutes by default", () => {
|
||||
cache.set(testKey, testData);
|
||||
expect(cache.get(testKey)).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if cached data is expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
vi.advanceTimersByTime(testTTL + 1);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes data from localStorage", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.delete(testKey);
|
||||
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all data with the app prefix from localStorage", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.set("anotherKey", { data: "More data" }, testTTL);
|
||||
cache.clearAll();
|
||||
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not retrieve non-prefixed data from localStorage when clearing", () => {
|
||||
localStorage.setItem("nonPrefixedKey", "should remain");
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.clearAll();
|
||||
expect(localStorage.getItem("nonPrefixedKey")).toBe("should remain");
|
||||
});
|
||||
});
|
||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@ -63,6 +63,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@ -7923,6 +7924,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
|
||||
@ -45,8 +45,8 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true remix vite:dev",
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
|
||||
"build": "npm run make-i18n && tsc && remix vite:build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
@ -89,6 +89,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@ -119,4 +120,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -122,6 +122,9 @@ export const retrieveGitHubUser = async (
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
@ -136,33 +139,6 @@ export const retrieveGitHubUser = async (
|
||||
return error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token and a repository name, creates a repository for the authenticated user
|
||||
* @param token The GitHub token
|
||||
* @param repositoryName Name of the repository to create
|
||||
* @param description Description of the repository
|
||||
* @param isPrivate Boolean indicating if the repository should be private
|
||||
* @returns The created repository or an error response
|
||||
*/
|
||||
export const createGitHubRepository = async (
|
||||
token: string,
|
||||
repositoryName: string,
|
||||
description?: string,
|
||||
isPrivate = true,
|
||||
): Promise<GitHubRepository | GitHubErrorReponse> => {
|
||||
const response = await fetch("https://api.github.com/user/repos", {
|
||||
method: "POST",
|
||||
headers: generateGitHubAPIHeaders(token),
|
||||
body: JSON.stringify({
|
||||
name: repositoryName,
|
||||
description,
|
||||
private: isPrivate,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
token: string,
|
||||
repository: string,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { request } from "#/services/api";
|
||||
import { cache } from "#/utils/cache";
|
||||
import {
|
||||
SaveFileSuccessResponse,
|
||||
FileUploadSuccessResponse,
|
||||
@ -15,7 +16,13 @@ class OpenHands {
|
||||
* @returns List of models available
|
||||
*/
|
||||
static async getModels(): Promise<string[]> {
|
||||
return request("/api/options/models");
|
||||
const cachedData = cache.get<string[]>("models");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/api/options/models");
|
||||
cache.set("models", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -23,7 +30,13 @@ class OpenHands {
|
||||
* @returns List of agents available
|
||||
*/
|
||||
static async getAgents(): Promise<string[]> {
|
||||
return request(`/api/options/agents`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/agents`);
|
||||
cache.set("agents", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,11 +44,23 @@ class OpenHands {
|
||||
* @returns List of security analyzers available
|
||||
*/
|
||||
static async getSecurityAnalyzers(): Promise<string[]> {
|
||||
return request(`/api/options/security-analyzers`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/security-analyzers`);
|
||||
cache.set("security-analyzers", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
return request("/config.json");
|
||||
const cachedData = cache.get<GetConfigResponse>("config");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/config.json");
|
||||
cache.set("config", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -25,7 +25,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark}>
|
||||
<SyntaxHighlighter
|
||||
language="python"
|
||||
style={atomOneDark}
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
@ -78,7 +82,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function JupyterEditor(): JSX.Element {
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { cells } = useSelector((state: RootState) => state.jupyter);
|
||||
@ -88,7 +96,7 @@ function JupyterEditor(): JSX.Element {
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1" style={{ maxWidth }}>
|
||||
<div
|
||||
className="overflow-y-auto h-full"
|
||||
ref={jupyterRef}
|
||||
|
||||
@ -16,6 +16,7 @@ interface ChatInputProps {
|
||||
onChange?: (message: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
}
|
||||
|
||||
@ -32,9 +33,46 @@ export function ChatInput({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImagePaste,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (files.length > 0) onImagePaste(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
if (onImagePaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (files.length > 0) {
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMessage = () => {
|
||||
if (textareaRef.current?.value) {
|
||||
@ -67,12 +105,20 @@ export function ChatInput({
|
||||
onChange={handleChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
value={value}
|
||||
minRows={1}
|
||||
maxRows={maxRows}
|
||||
data-dragging-over={isDraggingOver}
|
||||
className={cn(
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
|
||||
"transition-[height] duration-200 ease-in-out",
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
isDraggingOver
|
||||
? "bg-neutral-600/50 rounded-lg px-2"
|
||||
: "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
@ -18,6 +19,9 @@ import ConfirmationButtons from "./chat/ConfirmationButtons";
|
||||
import { ErrorMessage } from "./error-message";
|
||||
import { ContinueButton } from "./continue-button";
|
||||
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { Suggestions } from "./suggestions";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import BuildIt from "#/assets/build-it.svg?react";
|
||||
|
||||
const isErrorMessage = (
|
||||
message: Message | ErrorMessage,
|
||||
@ -37,17 +41,23 @@ export function ChatInterface() {
|
||||
"positive" | "negative"
|
||||
>("positive");
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
posthog.capture("user_message_sent", {
|
||||
current_message_count: messages.length,
|
||||
});
|
||||
const promises = files.map((file) => convertImageToBase64(file));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
dispatch(addUserMessage({ content, imageUrls, timestamp }));
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
setMessageToSend(null);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
@ -64,6 +74,28 @@ export function ChatInterface() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
|
||||
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
Let's start building!
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={(value) => {
|
||||
setMessageToSend(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
@ -123,6 +155,8 @@ export function ChatInterface() {
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
|
||||
value={messageToSend ?? undefined}
|
||||
onChange={setMessageToSend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface CustomInputProps {
|
||||
name: string;
|
||||
label: string;
|
||||
@ -13,12 +16,19 @@ export function CustomInput({
|
||||
defaultValue,
|
||||
type = "text",
|
||||
}: CustomInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor={name} className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3]">
|
||||
{label}
|
||||
{required && <span className="text-[#FF4D4F]">*</span>}
|
||||
{!required && <span className="text-[#A3A3A3]"> (optional)</span>}
|
||||
{!required && (
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
id={name}
|
||||
|
||||
@ -5,16 +5,18 @@ import {
|
||||
Switch,
|
||||
} from "@nextui-org/react";
|
||||
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@ -35,6 +37,7 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetcher = useFetcher<typeof clientAction>();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
@ -161,7 +164,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Advanced Options
|
||||
{t(I18nKey.SETTINGS_FORM$ADVANCED_OPTIONS_LABEL)}
|
||||
</Switch>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
@ -171,7 +174,7 @@ export function SettingsForm({
|
||||
htmlFor="custom-model"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Custom Model
|
||||
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@ -190,7 +193,7 @@ export function SettingsForm({
|
||||
htmlFor="base-url"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Base URL
|
||||
{t(I18nKey.SETTINGS_FORM$BASE_URL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@ -220,7 +223,7 @@ export function SettingsForm({
|
||||
htmlFor="api-key"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
API Key
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@ -234,14 +237,14 @@ export function SettingsForm({
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-[#A3A3A3]">
|
||||
Don't know your API key?{" "}
|
||||
{t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Click here for instructions
|
||||
{t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
@ -255,7 +258,7 @@ export function SettingsForm({
|
||||
htmlFor="agent"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Agent
|
||||
{t(I18nKey.SETTINGS_FORM$AGENT_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@ -291,7 +294,7 @@ export function SettingsForm({
|
||||
htmlFor="security-analyzer"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Security Analyzer (Optional)
|
||||
{t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@ -334,7 +337,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Enable Confirmation Mode
|
||||
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
@ -345,18 +348,18 @@ export function SettingsForm({
|
||||
<ModalButton
|
||||
disabled={disabled || fetcher.state === "submitting"}
|
||||
type="submit"
|
||||
text="Save"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
className="bg-[#4465DB] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.SETTINGS_FORM$CLOSE_LABEL)}
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
disabled={disabled}
|
||||
text="Reset to defaults"
|
||||
text={t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL)}
|
||||
variant="text-like"
|
||||
className="text-danger self-start"
|
||||
onClick={() => {
|
||||
@ -369,15 +372,17 @@ export function SettingsForm({
|
||||
{confirmResetDefaultsModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="Are you sure?"
|
||||
description="All saved information in your AI settings will be deleted including any API keys."
|
||||
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: "Reset Defaults",
|
||||
text: t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL),
|
||||
onClick: handleConfirmResetSettings,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmResetDefaultsModalOpen(false),
|
||||
},
|
||||
}}
|
||||
@ -387,12 +392,17 @@ export function SettingsForm({
|
||||
{confirmEndSessionModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="End Session"
|
||||
description="Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?"
|
||||
title={t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: { text: "End Session", onClick: handleConfirmEndSession },
|
||||
danger: {
|
||||
text: t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL),
|
||||
onClick: handleConfirmEndSession,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmEndSessionModalOpen(false),
|
||||
},
|
||||
}}
|
||||
|
||||
@ -9,6 +9,8 @@ interface InteractiveChatBoxProps {
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[]) => void;
|
||||
onStop: () => void;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
@ -16,6 +18,8 @@ export function InteractiveChatBox({
|
||||
mode = "submit",
|
||||
onSubmit,
|
||||
onStop,
|
||||
value,
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
|
||||
@ -53,6 +57,13 @@ export function InteractiveChatBox({
|
||||
className={cn(
|
||||
"flex items-end gap-1",
|
||||
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
@ -60,8 +71,11 @@ export function InteractiveChatBox({
|
||||
disabled={isDisabled}
|
||||
button={mode}
|
||||
placeholder="What do you want to build?"
|
||||
onChange={onChange}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onImagePaste={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
|
||||
import ModalBody from "./ModalBody";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@ -8,6 +9,7 @@ import { CustomInput } from "../form/custom-input";
|
||||
import { clientAction as settingsClientAction } from "#/routes/settings";
|
||||
import { clientAction as loginClientAction } from "#/routes/login";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
@ -23,6 +25,7 @@ function AccountSettingsModal({
|
||||
analyticsConsent,
|
||||
}: AccountSettingsModalProps) {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
const { t } = useTranslation();
|
||||
const settingsFetcher = useFetcher<typeof settingsClientAction>({
|
||||
key: "settings",
|
||||
});
|
||||
@ -85,13 +88,13 @@ function AccountSettingsModal({
|
||||
/>
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
GitHub token is invalid. Please try again.
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{ghToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text="Disconnect"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
onClick={() => {
|
||||
settingsFetcher.submit(
|
||||
{},
|
||||
@ -121,11 +124,11 @@ function AccountSettingsModal({
|
||||
}
|
||||
type="submit"
|
||||
intent="account"
|
||||
text="Save"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@ -7,10 +8,11 @@ import ModalButton from "../buttons/ModalButton";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function ConnectToGitHubByTokenModal() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody testID="auth-modal">
|
||||
<div className="flex flex-col gap-2">
|
||||
@ -29,13 +31,18 @@ function ConnectToGitHubByTokenModal() {
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
By connecting you agree to our{" "}
|
||||
<span className="text-hyperlink">terms of service</span>.
|
||||
{t(
|
||||
I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE,
|
||||
)}{" "}
|
||||
<span className="text-hyperlink">
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE)}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</label>
|
||||
<ModalButton
|
||||
type="submit"
|
||||
text="Continue"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE)}
|
||||
className="bg-[#791B80] w-full"
|
||||
disabled={navigation.state === "loading"}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size: "small" | "large";
|
||||
@ -28,10 +30,12 @@ interface LoadingProjectModalProps {
|
||||
}
|
||||
|
||||
function LoadingProjectModal({ message }: LoadingProjectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
|
||||
{message || "Loading..."}
|
||||
{message || t(I18nKey.LOADING_PROJECT$LOADING)}
|
||||
</span>
|
||||
<LoadingSpinner size="large" />
|
||||
</ModalBody>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@ -7,6 +8,7 @@ import {
|
||||
BaseModalTitle,
|
||||
} from "./confirmation-modals/BaseModal";
|
||||
import { clientAction } from "#/routes/login";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
@ -15,6 +17,7 @@ interface ConnectToGitHubModalProps {
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
@ -23,14 +26,14 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
<BaseModalDescription
|
||||
description={
|
||||
<span>
|
||||
Get your token{" "}
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
here
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@ -54,13 +57,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
type="submit"
|
||||
text="Connect"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
|
||||
disabled={fetcher.state === "submitting"}
|
||||
className="bg-[#791B80] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onClose}
|
||||
text="Close"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SecurityInvariant from "./invariant/Invariant";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SecurityProps {
|
||||
isOpen: boolean;
|
||||
@ -17,11 +19,13 @@ const SecurityAnalyzers: Record<SecurityAnalyzerOption, React.ElementType> = {
|
||||
};
|
||||
|
||||
function Security({ isOpen, onOpenChange, securityAnalyzer }: SecurityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const AnalyzerComponent =
|
||||
securityAnalyzer &&
|
||||
SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
? SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
: () => <div>Unknown security analyzer chosen</div>;
|
||||
: () => <div>{t(I18nKey.SECURITY$UNKNOWN_ANALYZER_LABEL)}</div>;
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
|
||||
@ -123,7 +123,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
|
||||
async function exportTraces(): Promise<void> {
|
||||
const data = await request(`/api/security/export-trace`);
|
||||
toast.info("Trace exported");
|
||||
toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE));
|
||||
|
||||
const filename = `openhands-trace-${getFormattedDateTime()}.json`;
|
||||
downloadJSON(data, filename);
|
||||
@ -134,7 +134,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ policy }),
|
||||
});
|
||||
toast.info("Policy updated");
|
||||
toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
async function updateSettings(): Promise<void> {
|
||||
@ -143,7 +143,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.info("Settings updated");
|
||||
toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
const handleExportTraces = useCallback(() => {
|
||||
@ -162,9 +162,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
logs: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Logs</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
|
||||
<Button onClick={handleExportTraces} className="bg-neutral-700">
|
||||
Export Trace
|
||||
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
|
||||
@ -195,9 +195,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
policy: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Policy</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdatePolicy}>
|
||||
Update Policy
|
||||
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-center">
|
||||
@ -214,14 +214,16 @@ function SecurityInvariant(): JSX.Element {
|
||||
settings: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Settings</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdateSettings}>
|
||||
Update Settings
|
||||
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow p-4">
|
||||
<div className="flex flex-col w-full">
|
||||
<p className="mb-2">Ask for user confirmation on risk severity:</p>
|
||||
<p className="mb-2">
|
||||
{t(I18nKey.INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL)}
|
||||
</p>
|
||||
<Select
|
||||
placeholder="Select risk severity"
|
||||
value={selectedRisk}
|
||||
@ -264,7 +266,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
key={ActionSecurityRisk.HIGH + 1}
|
||||
aria-label="Don't ask for confirmation"
|
||||
>
|
||||
Don't ask for confirmation
|
||||
{t(I18nKey.INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL)}
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
@ -278,18 +280,17 @@ function SecurityInvariant(): JSX.Element {
|
||||
<div className="w-60 bg-neutral-800 border-r border-r-neutral-600 p-4 flex-shrink-0">
|
||||
<div className="text-center mb-2">
|
||||
<InvariantLogoIcon className="mx-auto mb-1" />
|
||||
<b>Invariant Analyzer</b>
|
||||
<b>{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}</b>
|
||||
</div>
|
||||
<p className="text-[0.6rem]">
|
||||
Invariant Analyzer continuously monitors your OpenHands agent for
|
||||
security issues.{" "}
|
||||
{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_MESSAGE)}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/invariantlabs-ai/invariant"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Click to learn more
|
||||
{t(I18nKey.INVARIANT$CLICK_TO_LEARN_MORE_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
<hr className="border-t border-neutral-600 my-2" />
|
||||
@ -298,19 +299,19 @@ function SecurityInvariant(): JSX.Element {
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "logs" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("logs")}
|
||||
>
|
||||
Logs
|
||||
{t(I18nKey.INVARIANT$LOG_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "policy" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("policy")}
|
||||
>
|
||||
Policy
|
||||
{t(I18nKey.INVARIANT$POLICY_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "settings" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("settings")}
|
||||
>
|
||||
Settings
|
||||
{t(I18nKey.INVARIANT$SETTINGS_LABEL)}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import posthog from "posthog-js";
|
||||
import EllipsisH from "#/assets/ellipsis-h.svg?react";
|
||||
import { ModalBackdrop } from "../modals/modal-backdrop";
|
||||
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
|
||||
@ -37,6 +38,7 @@ export function ProjectMenuCard({
|
||||
};
|
||||
|
||||
const handlePushToGitHub = () => {
|
||||
posthog.capture("push_to_github_button_clicked");
|
||||
const rawEvent = {
|
||||
content: `
|
||||
Let's push the code to GitHub.
|
||||
@ -58,6 +60,15 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
setContextMenuIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{contextMenuIsOpen && (
|
||||
@ -65,13 +76,7 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onPushToGitHub={handlePushToGitHub}
|
||||
onDownloadWorkspace={() => {
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
}}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/assets/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@ -10,9 +12,13 @@ export function ProjectMenuDetailsPlaceholder({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
}: ProjectMenuDetailsPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm leading-6 font-semibold">New Project</span>
|
||||
<span className="text-sm leading-6 font-semibold">
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectToGitHub}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExternalLinkIcon from "#/assets/external-link.svg?react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsProps {
|
||||
repoName: string;
|
||||
@ -12,6 +14,7 @@ export function ProjectMenuDetails({
|
||||
avatar,
|
||||
lastCommit,
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
@ -32,7 +35,8 @@ export function ProjectMenuDetails({
|
||||
>
|
||||
<span>{lastCommit.sha.slice(-7)}</span> <span>·</span>{" "}
|
||||
<span>
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))} ago
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
21
frontend/src/components/suggestion-item.tsx
Normal file
21
frontend/src/components/suggestion-item.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
export type Suggestion = { label: string; value: string };
|
||||
|
||||
interface SuggestionItemProps {
|
||||
suggestion: Suggestion;
|
||||
onClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
return (
|
||||
<li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="suggestion"
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/suggestions.tsx
Normal file
23
frontend/src/components/suggestions.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { SuggestionItem, type Suggestion } from "./suggestion-item";
|
||||
|
||||
interface SuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onSuggestionClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Suggestions({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
}: SuggestionsProps) {
|
||||
return (
|
||||
<ul data-testid="suggestions" className="flex flex-col gap-4 w-full">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<SuggestionItem
|
||||
key={index}
|
||||
suggestion={suggestion}
|
||||
onClick={onSuggestionClick}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Data } from "ws";
|
||||
import posthog from "posthog-js";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
|
||||
interface WebSocketClientOptions {
|
||||
@ -58,6 +59,7 @@ function SocketProvider({ children }: SocketProviderProps) {
|
||||
]);
|
||||
|
||||
ws.addEventListener("open", (event) => {
|
||||
posthog.capture("socket_opened");
|
||||
setIsConnected(true);
|
||||
options?.onOpen?.(event);
|
||||
});
|
||||
@ -70,11 +72,13 @@ function SocketProvider({ children }: SocketProviderProps) {
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
posthog.capture("socket_error");
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
options?.onError?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
posthog.capture("socket_closed");
|
||||
EventLogger.event(event, "SOCKET CLOSE");
|
||||
|
||||
setIsConnected(false);
|
||||
|
||||
@ -798,7 +798,96 @@
|
||||
"tr": "İptal"
|
||||
},
|
||||
"FEEDBACK$EMAIL_PLACEHOLDER": {
|
||||
"en": "Enter your email address."
|
||||
"en": "Enter your email address",
|
||||
"es": "Ingresa tu correo electrónico"
|
||||
},
|
||||
"FEEDBACK$PASSWORD_COPIED_MESSAGE": {
|
||||
"en": "Password copied to clipboard.",
|
||||
"es": "Contraseña copiada al portapapeles."
|
||||
},
|
||||
"FEEDBACK$GO_TO_FEEDBACK": {
|
||||
"en": "Go to shared feedback",
|
||||
"es": "Ir a feedback compartido"
|
||||
},
|
||||
"FEEDBACK$PASSWORD": {
|
||||
"en": "Password:",
|
||||
"es": "Contraseña:"
|
||||
},
|
||||
"FEEDBACK$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Invalid email format",
|
||||
"es": "Formato de correo inválido"
|
||||
},
|
||||
"FEEDBACK$FAILED_TO_SHARE": {
|
||||
"en": "Failed to share, please contact the developers:",
|
||||
"es": "Error al compartir, por favor contacta con los desarrolladores:"
|
||||
},
|
||||
"FEEDBACK$COPY_LABEL": {
|
||||
"en": "Copy",
|
||||
"es": "Copiar"
|
||||
},
|
||||
"FEEDBACK$SHARING_SETTINGS_LABEL": {
|
||||
"en": "Sharing settings",
|
||||
"es": "Configuración de compartir"
|
||||
},
|
||||
"SECURITY$UNKNOWN_ANALYZER_LABEL":{
|
||||
"en": "Unknown security analyzer chosen",
|
||||
"es": "Analizador de seguridad desconocido"
|
||||
},
|
||||
"INVARIANT$UPDATE_POLICY_LABEL": {
|
||||
"en": "Update Policy",
|
||||
"es": "Actualizar política"
|
||||
},
|
||||
"INVARIANT$UPDATE_SETTINGS_LABEL": {
|
||||
"en": "Update Settings",
|
||||
"es": "Actualizar configuración"
|
||||
},
|
||||
"INVARIANT$SETTINGS_LABEL": {
|
||||
"en": "Settings",
|
||||
"es": "Configuración"
|
||||
},
|
||||
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
|
||||
"en": "Ask for user confirmation on risk severity:",
|
||||
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:"
|
||||
},
|
||||
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
|
||||
"en": "Don't ask for confirmation",
|
||||
"es": "No solicitar confirmación"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_LABEL": {
|
||||
"en": "Invariant Analyzer",
|
||||
"es": "Analizador de invariantes"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
|
||||
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
|
||||
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad."
|
||||
},
|
||||
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
|
||||
"en": "Click to learn more",
|
||||
"es": "Clic para aprender más"
|
||||
},
|
||||
"INVARIANT$POLICY_LABEL": {
|
||||
"en": "Policy",
|
||||
"es": "Política"
|
||||
},
|
||||
"INVARIANT$LOG_LABEL": {
|
||||
"en": "Logs",
|
||||
"es": "Logs"
|
||||
},
|
||||
"INVARIANT$EXPORT_TRACE_LABEL": {
|
||||
"en": "Export Trace",
|
||||
"es": "Exportar traza"
|
||||
},
|
||||
"INVARIANT$TRACE_EXPORTED_MESSAGE": {
|
||||
"en": "Trace exported",
|
||||
"es": "Traza exportada"
|
||||
},
|
||||
"INVARIANT$POLICY_UPDATED_MESSAGE": {
|
||||
"en": "Policy updated",
|
||||
"es": "Política actualizada"
|
||||
},
|
||||
"INVARIANT$SETTINGS_UPDATED_MESSAGE": {
|
||||
"en": "Settings updated",
|
||||
"es": "Configuración actualizada"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Starting up!",
|
||||
@ -1517,6 +1606,134 @@
|
||||
"fr": "En attente que le client soit prêt...",
|
||||
"tr": "İstemcinin hazır olması bekleniyor..."
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$DISCONNECT":{
|
||||
"en": "Disconnect",
|
||||
"es": "Desconectar"
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$SAVE":{
|
||||
"en": "Save",
|
||||
"es": "Guardar"
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
|
||||
"en": "Close",
|
||||
"es": ""
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
|
||||
"en": "GitHub token is invalid. Please try again.",
|
||||
"es": ""
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN": {
|
||||
"en": "Get your token",
|
||||
"es": "Obten tu token"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$HERE": {
|
||||
"en": "here",
|
||||
"es": "aquí"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$CONNECT": {
|
||||
"en": "Connect",
|
||||
"es": "Conectar"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$CLOSE": {
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE": {
|
||||
"en": "By connecting you agree to our",
|
||||
"es": "Al conectarte tu aceptas nuestros"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": {
|
||||
"en": "terms of service",
|
||||
"es": "términos de servicio"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE": {
|
||||
"en": "Continue",
|
||||
"es": "Continuar"
|
||||
},
|
||||
"LOADING_PROJECT$LOADING": {
|
||||
"en": "Loading...",
|
||||
"es": "Cargando..."
|
||||
},
|
||||
"CUSTOM_INPUT$OPTIONAL_LABEL": {
|
||||
"en": "(Optional)",
|
||||
"es": "(Opcional)"
|
||||
},
|
||||
"SETTINGS_FORM$ADVANCED_OPTIONS_LABEL": {
|
||||
"en": "Advanced Options",
|
||||
"es": "Opciones avanzadas"
|
||||
},
|
||||
"SETTINGS_FORM$CUSTOM_MODEL_LABEL": {
|
||||
"en": "Custom Model",
|
||||
"es": "Modelo personalizado"
|
||||
},
|
||||
"SETTINGS_FORM$BASE_URL_LABEL": {
|
||||
"en": "Base URL",
|
||||
"es": "URL base"
|
||||
},
|
||||
"SETTINGS_FORM$API_KEY_LABEL": {
|
||||
"en": "API Key",
|
||||
"es": "API Key"
|
||||
},
|
||||
"SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL": {
|
||||
"en": "Don't know your API key?",
|
||||
"es": "¿No sabes tu API key?"
|
||||
},
|
||||
"SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL": {
|
||||
"en": "Click here for instructions",
|
||||
"es": "Clic aquí para instrucciones"
|
||||
},
|
||||
"SETTINGS_FORM$AGENT_LABEL": {
|
||||
"en": "Agent",
|
||||
"es": "Agente"
|
||||
},
|
||||
"SETTINGS_FORM$SECURITY_ANALYZER_LABEL": {
|
||||
"en": "Security Analyzer (Optional)",
|
||||
"es": "Analizador de seguridad (opcional)"
|
||||
},
|
||||
"SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL": {
|
||||
"en": "Enable Confirmation Mode",
|
||||
"es": "Habilitar modo de confirmación"
|
||||
},
|
||||
"SETTINGS_FORM$SAVE_LABEL": {
|
||||
"en": "Save",
|
||||
"es": "Guardar"
|
||||
},
|
||||
"SETTINGS_FORM$CLOSE_LABEL": {
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
},
|
||||
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
|
||||
"en": "Reset to defaults",
|
||||
"es": "Reiniciar valores por defect"
|
||||
},
|
||||
"SETTINGS_FORM$CANCEL_LABEL": {
|
||||
"en": "Cancel",
|
||||
"es": "Cancelar"
|
||||
},
|
||||
"SETTINGS_FORM$END_SESSION_LABEL": {
|
||||
"en": "End Session",
|
||||
"es": "Terminar sesión"
|
||||
},
|
||||
"SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE": {
|
||||
"en": "Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?",
|
||||
"es": "Cambiar tu configuración limpiará tu espacio de trabajo e iniciará una nueva sesión. ¿Estás seguro de continuar?"
|
||||
},
|
||||
"SETTINGS_FORM$ARE_YOU_SURE_LABEL": {
|
||||
"en": "Are you sure?",
|
||||
"es": "¿Estás seguro?"
|
||||
},
|
||||
"SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE": {
|
||||
"en": "All saved information in your AI settings will be deleted, including any API keys.",
|
||||
"es": "Toda la información guardada en tu configuración de IA será eliminada, incluyendo tus API Keys"
|
||||
},
|
||||
"PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL": {
|
||||
"en":"New Project",
|
||||
"es":"Nuevo proyecto"
|
||||
},
|
||||
"PROJECT_MENU_DETAILS$AGO_LABEL": {
|
||||
"en":"ago",
|
||||
"es":"atrás"
|
||||
},
|
||||
"STATUS$ERROR_LLM_AUTHENTICATION": {
|
||||
"en": "Error authenticating with the LLM provider. Please check your API key"
|
||||
},
|
||||
|
||||
@ -92,6 +92,9 @@ export const handlers = [
|
||||
id: 1,
|
||||
login: "octocat",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
|
||||
company: "GitHub",
|
||||
email: "placeholder@placeholder.placeholder",
|
||||
name: "monalisa octocat",
|
||||
};
|
||||
|
||||
return HttpResponse.json(user);
|
||||
|
||||
@ -8,17 +8,23 @@ import {
|
||||
useNavigate,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import React, { Suspense } from "react";
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { SuggestionBox } from "./suggestion-box";
|
||||
import { TaskForm } from "./task-form";
|
||||
import { HeroHeading } from "./hero-heading";
|
||||
import { retrieveAllGitHubUserRepositories } from "#/api/github";
|
||||
import store from "#/store";
|
||||
import { setInitialQuery } from "#/state/initial-query-slice";
|
||||
import {
|
||||
setImportedProjectZip,
|
||||
setInitialQuery,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
|
||||
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
|
||||
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
|
||||
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
let isSaas = false;
|
||||
@ -59,14 +65,18 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
const q = formData.get("q")?.toString();
|
||||
if (q) store.dispatch(setInitialQuery(q));
|
||||
|
||||
posthog.capture("initial_query_submitted", {
|
||||
query_character_length: q?.length,
|
||||
});
|
||||
|
||||
return redirect("/app");
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
|
||||
const [importedFile, setImportedFile] = React.useState<File | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -76,10 +86,10 @@ function Home() {
|
||||
<HeroHeading />
|
||||
<div className="flex flex-col gap-16 w-[600px] items-center">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<TaskForm importedProjectZip={importedFile} />
|
||||
<TaskForm />
|
||||
</div>
|
||||
<div className="flex gap-4 w-full">
|
||||
<Suspense
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<SuggestionBox
|
||||
title="Open a Repo"
|
||||
@ -96,36 +106,36 @@ function Home() {
|
||||
/>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</React.Suspense>
|
||||
<SuggestionBox
|
||||
title={importedFile ? "Project Loaded" : "+ Import Project"}
|
||||
title="+ Import Project"
|
||||
content={
|
||||
importedFile?.name ?? (
|
||||
<label
|
||||
htmlFor="import-project"
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
|
||||
Upload a .zip
|
||||
</span>
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/zip"
|
||||
id="import-project"
|
||||
multiple={false}
|
||||
onChange={(event) => {
|
||||
if (event.target.files) {
|
||||
const zip = event.target.files[0];
|
||||
setImportedFile(zip);
|
||||
navigate("/app");
|
||||
} else {
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
<label
|
||||
htmlFor="import-project"
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
|
||||
Upload a .zip
|
||||
</span>
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/zip"
|
||||
id="import-project"
|
||||
multiple={false}
|
||||
onChange={async (event) => {
|
||||
if (event.target.files) {
|
||||
const zip = event.target.files[0];
|
||||
dispatch(
|
||||
setImportedProjectZip(await convertZipToBase64(zip)),
|
||||
);
|
||||
navigate("/app");
|
||||
} else {
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2,11 +2,7 @@ import React from "react";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import {
|
||||
addFile,
|
||||
removeFile,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { addFile, removeFile } from "#/state/initial-query-slice";
|
||||
import { SuggestionBubble } from "#/components/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@ -14,15 +10,10 @@ import { ChatInput } from "#/components/chat-input";
|
||||
import { UploadImageInput } from "#/components/upload-image-input";
|
||||
import { ImageCarousel } from "#/components/image-carousel";
|
||||
import { getRandomKey } from "#/utils/get-random-key";
|
||||
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { AttachImageLabel } from "#/components/attach-image-label";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface TaskFormProps {
|
||||
importedProjectZip: File | null;
|
||||
}
|
||||
|
||||
export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
export function TaskForm() {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
@ -30,29 +21,15 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
|
||||
const hasLoadedProject = React.useMemo(
|
||||
() => importedProjectZip || selectedRepository,
|
||||
[importedProjectZip, selectedRepository],
|
||||
);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const [text, setText] = React.useState("");
|
||||
const [suggestion, setSuggestion] = React.useState(
|
||||
getRandomKey(hasLoadedProject ? SUGGESTIONS.repo : SUGGESTIONS["non-repo"]),
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Display a suggestion based on whether a repository is selected
|
||||
if (hasLoadedProject) {
|
||||
setSuggestion(getRandomKey(SUGGESTIONS.repo));
|
||||
} else {
|
||||
setSuggestion(getRandomKey(SUGGESTIONS["non-repo"]));
|
||||
}
|
||||
}, [selectedRepository, importedProjectZip]);
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
// remove current suggestion to avoid refreshing to the same suggestion
|
||||
const suggestionCopy = { ...suggestions };
|
||||
delete suggestionCopy[suggestion];
|
||||
@ -62,20 +39,11 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
};
|
||||
|
||||
const onClickSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
const value = suggestions[suggestion];
|
||||
setText(value);
|
||||
};
|
||||
|
||||
const handleSubmitForm = async () => {
|
||||
// This is handled on top of the form submission
|
||||
if (importedProjectZip) {
|
||||
dispatch(
|
||||
setImportedProjectZip(await convertZipToBase64(importedProjectZip)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const placeholder = React.useMemo(() => {
|
||||
if (selectedRepository) {
|
||||
return `What would you like to change in ${selectedRepository}?`;
|
||||
@ -90,7 +58,6 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
ref={formRef}
|
||||
method="post"
|
||||
className="flex flex-col items-center gap-2"
|
||||
onSubmit={handleSubmitForm}
|
||||
replace
|
||||
>
|
||||
<SuggestionBubble
|
||||
@ -100,8 +67,14 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full",
|
||||
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
|
||||
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
@ -112,6 +85,13 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
onImagePaste={async (imageFiles) => {
|
||||
const promises = imageFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
|
||||
@ -1,7 +1,23 @@
|
||||
import React from "react";
|
||||
import JupyterEditor from "#/components/Jupyter";
|
||||
|
||||
function Jupyter() {
|
||||
return <JupyterEditor />;
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [parentWidth, setParentWidth] = React.useState(0);
|
||||
|
||||
// This is a hack to prevent the editor from overflowing
|
||||
// Should be removed after revising the parent and containers
|
||||
React.useEffect(() => {
|
||||
if (parentRef.current) {
|
||||
setParentWidth(parentRef.current.offsetWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={parentRef}>
|
||||
<JupyterEditor maxWidth={parentWidth} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Jupyter;
|
||||
|
||||
@ -36,6 +36,7 @@ import ListIcon from "#/assets/list-type-number.svg?react";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearInitialQuery,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
@ -48,6 +49,7 @@ import { clearJupyter } from "#/state/jupyterSlice";
|
||||
import { FilesProvider } from "#/context/files";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@ -254,6 +256,7 @@ function App() {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
|
||||
startSocketConnection();
|
||||
});
|
||||
|
||||
@ -291,7 +294,16 @@ function App() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-[390px] max-h-full">
|
||||
<Container className="w-[390px] max-h-full relative">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full border",
|
||||
"absolute left-3 top-3",
|
||||
runtimeActive
|
||||
? "bg-green-800 border-green-500"
|
||||
: "bg-red-800 border-red-500",
|
||||
)}
|
||||
/>
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
|
||||
|
||||
@ -51,13 +51,13 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
|
||||
if (!userConsents) {
|
||||
posthog.opt_out_capturing();
|
||||
} else {
|
||||
} else if (userConsents && !posthog.has_opted_in_capturing()) {
|
||||
posthog.opt_in_capturing();
|
||||
}
|
||||
|
||||
let isAuthed = false;
|
||||
let githubAuthUrl: string | null = null;
|
||||
|
||||
let user: GitHubUser | GitHubErrorReponse | null = null;
|
||||
try {
|
||||
isAuthed = await userIsAuthenticated();
|
||||
if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
|
||||
@ -72,7 +72,6 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
githubAuthUrl = null;
|
||||
}
|
||||
|
||||
let user: GitHubUser | GitHubErrorReponse | null = null;
|
||||
if (ghToken) user = await retrieveGitHubUser(ghToken);
|
||||
|
||||
const settings = getSettings();
|
||||
@ -84,6 +83,7 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
token = null;
|
||||
}
|
||||
|
||||
// Store the results in cache
|
||||
return defer({
|
||||
token,
|
||||
ghToken,
|
||||
@ -167,6 +167,16 @@ export default function MainApp() {
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user && !isGitHubErrorReponse(user)) {
|
||||
posthog.identify(user.login, {
|
||||
company: user.company,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// We fetch this here instead of the data loader because the server seems to block
|
||||
// the retrieval when the session is closing -- preventing the screen from rendering until
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { json } from "@remix-run/react";
|
||||
import posthog from "posthog-js";
|
||||
import { cache } from "#/utils/cache";
|
||||
|
||||
export const clientAction = () => {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
if (ghToken) localStorage.removeItem("ghToken");
|
||||
|
||||
cache.clearAll();
|
||||
posthog.reset();
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ClientActionFunctionArgs, json } from "@remix-run/react";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
getDefaultSettings,
|
||||
LATEST_SETTINGS_VERSION,
|
||||
@ -38,6 +39,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
saveSettings(getDefaultSettings());
|
||||
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
|
||||
|
||||
posthog.capture("settings_reset");
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
@ -97,5 +99,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
|
||||
|
||||
posthog.capture("settings_saved", {
|
||||
LLM_MODEL,
|
||||
LLM_API_KEY: LLM_API_KEY ? "SET" : "UNSET",
|
||||
});
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@ -9,10 +9,18 @@ import { addAssistantMessage } from "#/state/chatSlice";
|
||||
|
||||
export function handleObservationMessage(message: ObservationMessage) {
|
||||
switch (message.observation) {
|
||||
case ObservationType.RUN:
|
||||
case ObservationType.RUN: {
|
||||
if (message.extras.hidden) break;
|
||||
store.dispatch(appendOutput(message.content));
|
||||
let { content } = message;
|
||||
|
||||
if (content.length > 5000) {
|
||||
const head = content.slice(0, 5000);
|
||||
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...`;
|
||||
}
|
||||
|
||||
store.dispatch(appendOutput(content));
|
||||
break;
|
||||
}
|
||||
case ObservationType.RUN_IPYTHON:
|
||||
// FIXME: render this as markdown
|
||||
store.dispatch(appendJupyterOutput(message.content));
|
||||
|
||||
@ -30,6 +30,9 @@ export const selectedFilesSlice = createSlice({
|
||||
setInitialQuery(state, action: PayloadAction<string>) {
|
||||
state.initialQuery = action.payload;
|
||||
},
|
||||
clearInitialQuery(state) {
|
||||
state.initialQuery = null;
|
||||
},
|
||||
setSelectedRepository(state, action: PayloadAction<string | null>) {
|
||||
state.selectedRepository = action.payload;
|
||||
},
|
||||
@ -47,6 +50,7 @@ export const {
|
||||
removeFile,
|
||||
clearFiles,
|
||||
setInitialQuery,
|
||||
clearInitialQuery,
|
||||
setSelectedRepository,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
|
||||
3
frontend/src/types/github.d.ts
vendored
3
frontend/src/types/github.d.ts
vendored
@ -8,6 +8,9 @@ interface GitHubUser {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
company: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface GitHubRepository {
|
||||
|
||||
70
frontend/src/utils/cache.ts
Normal file
70
frontend/src/utils/cache.ts
Normal file
@ -0,0 +1,70 @@
|
||||
type CacheKey = string;
|
||||
type CacheEntry<T> = {
|
||||
data: T;
|
||||
expiration: number;
|
||||
};
|
||||
|
||||
class Cache {
|
||||
private prefix = "app_cache_";
|
||||
|
||||
private defaultTTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Generate a unique key with prefix for local storage
|
||||
* @param key The key to be stored in local storage
|
||||
* @returns The unique key with prefix
|
||||
*/
|
||||
private getKey(key: CacheKey): string {
|
||||
return `${this.prefix}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the cached data from local storage
|
||||
* @param key The key to be retrieved from local storage
|
||||
* @returns The data stored in local storage
|
||||
*/
|
||||
public get<T>(key: CacheKey): T | null {
|
||||
const cachedEntry = localStorage.getItem(this.getKey(key));
|
||||
if (cachedEntry) {
|
||||
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
|
||||
if (Date.now() < expiration) return data;
|
||||
this.delete(key); // Remove expired cache
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the data in local storage with expiration
|
||||
* @param key The key to be stored in local storage
|
||||
* @param data The data to be stored in local storage
|
||||
* @param ttl The time to live for the data in milliseconds
|
||||
* @returns void
|
||||
*/
|
||||
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
|
||||
const expiration = Date.now() + ttl;
|
||||
const entry: CacheEntry<T> = { data, expiration };
|
||||
localStorage.setItem(this.getKey(key), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the data from local storage
|
||||
* @param key The key to be removed from local storage
|
||||
* @returns void
|
||||
*/
|
||||
public delete(key: CacheKey): void {
|
||||
localStorage.removeItem(this.getKey(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data with the app prefix from local storage
|
||||
* @returns void
|
||||
*/
|
||||
public clearAll(): void {
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.startsWith(this.prefix)) localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new Cache();
|
||||
@ -1,12 +1,20 @@
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { cache } from "./cache";
|
||||
|
||||
export const userIsAuthenticated = async () => {
|
||||
if (window.__APP_MODE__ === "oss") return true;
|
||||
|
||||
const cachedData = cache.get<boolean>("user_is_authenticated");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
let authenticated = false;
|
||||
try {
|
||||
await OpenHands.authenticate();
|
||||
return true;
|
||||
authenticated = true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
authenticated = false;
|
||||
}
|
||||
|
||||
cache.set("user_is_authenticated", authenticated, 3 * 60 * 1000); // cache for 3 minutes
|
||||
return authenticated;
|
||||
};
|
||||
|
||||
@ -6,12 +6,14 @@ __package_name__ = 'openhands_ai'
|
||||
def get_version():
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
return version(__package_name__)
|
||||
except (ImportError, PackageNotFoundError):
|
||||
pass
|
||||
|
||||
try:
|
||||
from pkg_resources import DistributionNotFound, get_distribution
|
||||
|
||||
return get_distribution(__package_name__).version
|
||||
except (ImportError, DistributionNotFound):
|
||||
pass
|
||||
|
||||
@ -245,7 +245,7 @@ StrReplaceEditorTool = ChatCompletionToolParam(
|
||||
'type': 'string',
|
||||
},
|
||||
'path': {
|
||||
'description': 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
|
||||
'description': 'Absolute path to file or directory, e.g. `/workspace/file.py` or `/workspace`.',
|
||||
'type': 'string',
|
||||
},
|
||||
'file_text': {
|
||||
@ -284,6 +284,17 @@ _browser_action_space = HighLevelActionSet(
|
||||
|
||||
|
||||
_BROWSER_DESCRIPTION = """Interact with the browser using Python code.
|
||||
|
||||
See the description of "code" parameter for more details.
|
||||
|
||||
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
|
||||
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
|
||||
fill('a12', 'example with "quotes"')
|
||||
click('a51')
|
||||
click('48', button='middle', modifiers=['Shift'])
|
||||
"""
|
||||
|
||||
_BROWSER_TOOL_DESCRIPTION = """
|
||||
The following 15 functions are available. Nothing else is supported.
|
||||
|
||||
goto(url: str)
|
||||
@ -385,20 +396,15 @@ upload_file(bid: str, file: str | list[str])
|
||||
upload_file('572', '/home/user/my_receipt.pdf')
|
||||
|
||||
upload_file('63', ['/home/bob/Documents/image.jpg', '/home/bob/Documents/file.zip'])
|
||||
|
||||
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
|
||||
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
|
||||
fill('a12', 'example with "quotes"')
|
||||
click('a51')
|
||||
click('48', button='middle', modifiers=['Shift'])
|
||||
"""
|
||||
|
||||
|
||||
for _, action in _browser_action_space.action_set.items():
|
||||
assert (
|
||||
action.signature in _BROWSER_DESCRIPTION
|
||||
action.signature in _BROWSER_TOOL_DESCRIPTION
|
||||
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.signature}'
|
||||
assert (
|
||||
action.description in _BROWSER_DESCRIPTION
|
||||
action.description in _BROWSER_TOOL_DESCRIPTION
|
||||
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.description}'
|
||||
|
||||
BrowserTool = ChatCompletionToolParam(
|
||||
@ -411,7 +417,10 @@ BrowserTool = ChatCompletionToolParam(
|
||||
'properties': {
|
||||
'code': {
|
||||
'type': 'string',
|
||||
'description': 'The Python code that interacts with the browser.',
|
||||
'description': (
|
||||
'The Python code that interacts with the browser.\n'
|
||||
+ _BROWSER_TOOL_DESCRIPTION
|
||||
),
|
||||
}
|
||||
},
|
||||
'required': ['code'],
|
||||
|
||||
@ -342,9 +342,14 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# but model_info will have the correct value for some reason.
|
||||
# we can go with it, but we will need to keep an eye if model_info is correct for Vertex or other providers
|
||||
# remove when litellm is updated to fix https://github.com/BerriAI/litellm/issues/5608
|
||||
return litellm.supports_vision(self.config.model) or (
|
||||
self.model_info is not None
|
||||
and self.model_info.get('supports_vision', False)
|
||||
# Check both the full model name and the name after proxy prefix for vision support
|
||||
return (
|
||||
litellm.supports_vision(self.config.model)
|
||||
or litellm.supports_vision(self.config.model.split('/')[-1])
|
||||
or (
|
||||
self.model_info is not None
|
||||
and self.model_info.get('supports_vision', False)
|
||||
)
|
||||
)
|
||||
|
||||
def is_caching_prompt_active(self) -> bool:
|
||||
|
||||
@ -90,9 +90,8 @@ class RemoteRuntime(Runtime):
|
||||
self.runtime_url: str | None = None
|
||||
|
||||
async def connect(self):
|
||||
await call_sync_from_async(self._start_or_attach_to_runtime)
|
||||
try:
|
||||
await call_sync_from_async(self._wait_until_alive)
|
||||
await call_sync_from_async(self._start_or_attach_to_runtime)
|
||||
except RuntimeNotReadyError:
|
||||
self.log('error', 'Runtime failed to start, timed out before ready')
|
||||
raise
|
||||
@ -277,6 +276,14 @@ class RemoteRuntime(Runtime):
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
pod_status = runtime_data['pod_status']
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'Not Found':
|
||||
raise RuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
if pod_status == 'Ready':
|
||||
try:
|
||||
self._send_request(
|
||||
@ -291,7 +298,7 @@ class RemoteRuntime(Runtime):
|
||||
f'Runtime /alive failed to respond with 200: {e}'
|
||||
)
|
||||
return
|
||||
if pod_status in ('Failed', 'Unknown', 'Not Found'):
|
||||
if pod_status in ('Failed', 'Unknown'):
|
||||
# clean up the runtime
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
|
||||
@ -138,6 +138,10 @@ class Session:
|
||||
return
|
||||
if event.source == EventSource.AGENT:
|
||||
await self.send(event_to_dict(event))
|
||||
elif event.source == EventSource.USER and isinstance(
|
||||
event, CmdOutputObservation
|
||||
):
|
||||
await self.send(event_to_dict(event))
|
||||
# NOTE: ipython observations are not sent here currently
|
||||
elif event.source == EventSource.ENVIRONMENT and isinstance(
|
||||
event, (CmdOutputObservation, AgentStateChangedObservation)
|
||||
|
||||
@ -92,6 +92,7 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@ -122,6 +123,7 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user