Compare commits
69 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d9acfc86 | ||
|
|
0cfb132ab7 | ||
|
|
17f4c6e1a9 | ||
|
|
910b283ac2 | ||
|
|
b54724ac3f | ||
|
|
0633a99298 | ||
|
|
d9c5f11046 | ||
|
|
32fdcd58e5 | ||
|
|
de71b7cdb8 | ||
|
|
04aeccfb69 | ||
|
|
4eea1286d4 | ||
|
|
488a320ffd | ||
|
|
377fadc2eb | ||
|
|
7df7f43e3c | ||
|
|
a45aba512a | ||
|
|
a1a9d2f175 | ||
|
|
79492b6551 | ||
|
|
80fdb9a2f4 | ||
|
|
975e75531d | ||
|
|
1b5f5bcdad | ||
|
|
8c00d96024 | ||
|
|
bf8ccc8fc3 | ||
|
|
037d770f66 | ||
|
|
dd50246672 | ||
|
|
090771674c | ||
|
|
d8ab0208ba | ||
|
|
a07e8272da | ||
|
|
be82832eb1 | ||
|
|
67c8915d51 | ||
|
|
40b3ccb17c | ||
|
|
35c68863dc | ||
|
|
8bfee87bcf | ||
|
|
e1383afbc3 | ||
|
|
4ce3b9094a | ||
|
|
0a4e196670 | ||
|
|
8d32a59f55 | ||
|
|
38b92f4251 | ||
|
|
88dbe85594 | ||
|
|
f5003a7449 | ||
|
|
a6810fa6ad | ||
|
|
fc05d8d4eb | ||
|
|
1d6ef0e18e | ||
|
|
dc0e223d1a | ||
|
|
932de79154 | ||
|
|
fa625fed70 | ||
|
|
f9fa1d95cb | ||
|
|
5615d54f81 | ||
|
|
8166bf768a | ||
|
|
c3991c870d | ||
|
|
1a27619b39 | ||
|
|
cc15aee405 | ||
|
|
53390d9885 | ||
|
|
0335b1a634 | ||
|
|
bb362cd377 | ||
|
|
4405b109e3 | ||
|
|
47464a9cfa | ||
|
|
2b3fd94540 | ||
|
|
1bd46f3832 | ||
|
|
8a063fdf6a | ||
|
|
025dac5d8f | ||
|
|
0e5e754420 | ||
|
|
7a8e207985 | ||
|
|
a4de0f2142 | ||
|
|
27716171bf | ||
|
|
e5d7735d75 | ||
|
|
83ccb74d36 | ||
|
|
118957235d | ||
|
|
4a6406ed71 | ||
|
|
4bef974a89 |
2
.github/workflows/ghcr-build.yml
vendored
@@ -286,7 +286,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
@@ -364,7 +363,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
|
||||
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 }}
|
||||
max_iterations: 50
|
||||
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.13-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -38,15 +38,16 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -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.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -32,7 +32,8 @@ workspace_base = "./workspace"
|
||||
# Enable saving and restoring the session when run from CLI
|
||||
#enable_cli_session = false
|
||||
|
||||
# Path to store trajectories
|
||||
# Path to store trajectories, can be a folder or a file
|
||||
# If it's a folder, the session id will be used as the file name
|
||||
#trajectories_path="./trajectories"
|
||||
|
||||
# File store path
|
||||
|
||||
@@ -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.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -44,15 +44,16 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
|
||||
## Model Recommendations
|
||||
|
||||
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed).
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
|
||||
|
||||
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
|
||||
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands.
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
|
||||
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
|
||||
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ docker run # ...
|
||||
-e RUNTIME=remote \
|
||||
-e SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.app.all-hands.dev" \
|
||||
-e SANDBOX_API_KEY="your-all-hands-api-key" \
|
||||
-e SANDBOX_KEEP_REMOTE_RUNTIME_ALIVE="true" \
|
||||
-e SANDBOX_KEEP_RUNTIME_ALIVE="true" \
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
2715
docs/package-lock.json
generated
@@ -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
@@ -35,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
|
||||
|
||||
# retrieve the latest model message from history
|
||||
if state.history:
|
||||
model_guess = state.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_guess = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
assert game is not None, 'Game is not initialized.'
|
||||
msg = game.generate_user_response(model_guess)
|
||||
@@ -140,7 +141,8 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
final_message = state.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
final_message = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
|
||||
test_result = game.reward()
|
||||
|
||||
@@ -102,7 +102,8 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
ast_eval_fn = instance['ast_eval']
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_config(
|
||||
browsergym_eval_env=env_id,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -72,7 +72,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -83,6 +83,7 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
timeout=1800,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -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'
|
||||
@@ -145,7 +145,8 @@ def get_config(
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -127,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
correct = eval_answer(str(model_answer_raw), str(answer))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||
import { ChatInput } from "#/components/chat-input";
|
||||
|
||||
@@ -158,4 +158,46 @@ describe("ChatInput", () => {
|
||||
await user.tab();
|
||||
expect(onBlurMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle text paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Fire paste event with text data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
|
||||
files: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Create a paste event with an image file
|
||||
const file = new File(["dummy content"], "image.png", { type: "image/png" });
|
||||
|
||||
// Fire paste event with image data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: () => '',
|
||||
files: [file]
|
||||
}
|
||||
});
|
||||
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { useWsClient: useWsClientMock } = vi.hoisted(() => ({
|
||||
useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("#/context/socket", async (importActual) => ({
|
||||
...(await importActual<typeof import("#/context/ws-client-provider")>()),
|
||||
useWsClient: useWsClientMock,
|
||||
}));
|
||||
});
|
||||
|
||||
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
|
||||
useWsClientMock.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 () => {
|
||||
useWsClientMock.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();
|
||||
|
||||
useWsClientMock.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
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/useTerminal";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
@@ -18,6 +19,17 @@ function TestTerminalComponent({
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
function Wrapper({children}: WrapperProps) {
|
||||
return (
|
||||
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
|
||||
|
||||
it("should render", () => {
|
||||
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
|
||||
];
|
||||
|
||||
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
|
||||
secrets={[secret, anotherSecret]}
|
||||
/>,
|
||||
{
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
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
@@ -0,0 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("App", () => {
|
||||
it.todo("should render");
|
||||
});
|
||||
53
frontend/__tests__/utils/cache.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("gets data from memory 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();
|
||||
});
|
||||
|
||||
it("returns null if cached data is expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
vi.advanceTimersByTime(testTTL + 1);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes data from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.delete(testKey);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all data with the app prefix from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.set("anotherKey", { data: "More data" }, testTTL);
|
||||
cache.clearAll();
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(cache.get("anotherKey")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
|
||||
"gpt-4o",
|
||||
"together-ai-21.1b-41b",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
|
||||
23
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": ""
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const IgnoreTaskStateMap: Record<string, AgentState[]> = {
|
||||
[AgentState.PAUSED]: [
|
||||
@@ -72,7 +72,7 @@ function ActionButton({
|
||||
}
|
||||
|
||||
function AgentControlBar() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const handleAction = (action: AgentState) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
export function AttachImageLabel() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputProps {
|
||||
@@ -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,51 @@ 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>) => {
|
||||
// Only handle paste if we have an image paste handler and there are files
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
// Only prevent default if we found image files to handle
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
|
||||
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 +110,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,6 +1,6 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import posthog from "posthog-js";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { FeedbackActions } from "./feedback-actions";
|
||||
@@ -18,13 +18,17 @@ 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 "#/icons/build-it.svg?react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const isErrorMessage = (
|
||||
message: Message | ErrorMessage,
|
||||
): message is ErrorMessage => "error" in message;
|
||||
|
||||
export function ChatInterface() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ActionTooltipProps {
|
||||
type: "confirm" | "reject";
|
||||
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
|
||||
|
||||
function ConfirmationButtons() {
|
||||
const { t } = useTranslation();
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
|
||||
const handleStateChange = (state: AgentState) => {
|
||||
const event = generateAgentStateChangeEvent(state);
|
||||
|
||||
188
frontend/src/components/event-handler.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import {
|
||||
getCloneRepoCommand,
|
||||
getGitHubTokenCommand,
|
||||
} from "#/services/terminalService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
|
||||
import store, { RootState } from "#/store";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { setCurrentAgentState } from "#/state/agentSlice";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { getSettings } from "#/services/settings";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
const isErrorObservation = (data: object): data is ErrorObservation =>
|
||||
"observation" in data && data.observation === "error";
|
||||
|
||||
export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
const { events, status, send } = useWsClient();
|
||||
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
|
||||
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
|
||||
const fetcher = useFetcher();
|
||||
const dispatch = useDispatch();
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
|
||||
const initialQueryRef = React.useRef<string | null>(
|
||||
store.getState().initalQuery.initialQuery,
|
||||
);
|
||||
|
||||
const sendInitialQuery = (query: string, base64Files: string[]) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
send(createChatMessage(query, base64Files, timestamp));
|
||||
};
|
||||
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
const userId = React.useMemo(() => {
|
||||
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
|
||||
return null;
|
||||
}, [data?.user]);
|
||||
const userSettings = getSettings();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
const event = events[events.length - 1];
|
||||
if (event.token) {
|
||||
fetcher.submit({ token: event.token as string }, { method: "post" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
toast.error("Session expired.");
|
||||
fetcher.submit({}, { method: "POST", action: "/end-session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.error === "string") {
|
||||
toast.error(event.error);
|
||||
} else {
|
||||
toast.error(event.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
id: event.extras?.error_id,
|
||||
message: event.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleAssistantMessage(event);
|
||||
}, [events.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (statusRef.current === status) {
|
||||
return; // This is a check because of strict mode - if the status did not change, don't do anything
|
||||
}
|
||||
statusRef.current = status;
|
||||
const initialQuery = initialQueryRef.current;
|
||||
|
||||
if (status === WsClientProviderStatus.ACTIVE) {
|
||||
let additionalInfo = "";
|
||||
if (ghToken && repo) {
|
||||
send(getCloneRepoCommand(ghToken, repo));
|
||||
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
|
||||
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
|
||||
}
|
||||
// if there's an uploaded project zip, add it to the chat
|
||||
else if (importedProjectZip) {
|
||||
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
|
||||
}
|
||||
|
||||
if (initialQuery) {
|
||||
if (additionalInfo) {
|
||||
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
|
||||
} else {
|
||||
sendInitialQuery(initialQuery, files);
|
||||
}
|
||||
dispatch(clearFiles()); // reset selected files
|
||||
initialQueryRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.OPENING && initialQuery) {
|
||||
dispatch(
|
||||
addUserMessage({
|
||||
content: initialQuery,
|
||||
imageUrls: files,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.STOPPED) {
|
||||
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && ghToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(ghToken));
|
||||
}
|
||||
}, [userId, ghToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
// upload files action
|
||||
try {
|
||||
const blob = base64ToBlob(importedProjectZip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles([file]);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload project files.");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userSettings.LLM_API_KEY) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [userSettings.LLM_API_KEY]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,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),
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CloseIcon from "#/assets/close.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
|
||||
@@ -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,8 @@ 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",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
@@ -60,8 +66,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, useRouteLoaderData } 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";
|
||||
@@ -9,6 +10,7 @@ import { clientLoader } from "#/routes/_oh";
|
||||
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({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const settingsFetcher = useFetcher<typeof settingsClientAction>({
|
||||
key: "settings",
|
||||
@@ -86,13 +89,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>
|
||||
)}
|
||||
{data?.ghToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text="Disconnect"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
onClick={() => {
|
||||
settingsFetcher.submit(
|
||||
{},
|
||||
@@ -122,11 +125,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 LoadingSpinnerOuter from "#/assets/loading-outer.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoadingSpinnerOuter from "#/icons/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, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "./confirmation-modals/BaseModal";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction } from "#/routes/login";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
@@ -16,6 +18,7 @@ interface ConnectToGitHubModalProps {
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
@@ -24,14 +27,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>
|
||||
}
|
||||
@@ -55,13 +58,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,16 +1,18 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import EllipsisH from "#/assets/ellipsis-h.svg?react";
|
||||
import posthog from "posthog-js";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { ModalBackdrop } from "../modals/modal-backdrop";
|
||||
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { LoadingSpinner } from "../modals/LoadingProject";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -25,24 +27,23 @@ export function ProjectMenuCard({
|
||||
isConnectedToGitHub,
|
||||
githubData,
|
||||
}: ProjectMenuCardProps) {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [working, setWorking] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handlePushToGitHub = () => {
|
||||
posthog.capture("push_to_github_button_clicked");
|
||||
const rawEvent = {
|
||||
content: `
|
||||
Let's push the code to GitHub.
|
||||
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
|
||||
Commit any changes and push them to the remote repository.
|
||||
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
|
||||
Please push the changes to GitHub and open a pull request.
|
||||
`,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -58,20 +59,27 @@ 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 {
|
||||
setWorking(true);
|
||||
downloadWorkspace().then(
|
||||
() => setWorking(false),
|
||||
() => setWorking(false),
|
||||
);
|
||||
} 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 && (
|
||||
{!working && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onPushToGitHub={handlePushToGitHub}
|
||||
onDownloadWorkspace={() => {
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
}}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -93,7 +101,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
{working ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<EllipsisH width={36} height={36} />
|
||||
)}
|
||||
</button>
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/assets/cloud-connection.svg?react";
|
||||
import CloudConnection from "#/icons/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
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 ExternalLinkIcon from "#/assets/external-link.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExternalLinkIcon from "#/icons/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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardContextMenuProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
Connect to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onPushToGitHub}>
|
||||
Push to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
Download as .zip
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Lightbulb from "#/assets/lightbulb.svg?react";
|
||||
import Refresh from "#/assets/refresh.svg?react";
|
||||
import Lightbulb from "#/icons/lightbulb.svg?react";
|
||||
import Refresh from "#/icons/refresh.svg?react";
|
||||
|
||||
interface SuggestionBubbleProps {
|
||||
suggestion: string;
|
||||
|
||||
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
@@ -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,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
interface UploadImageInputProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "./modals/LoadingProject";
|
||||
import DefaultUserAvatar from "#/assets/default-user.svg?react";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface UserAvatarProps {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react";
|
||||
import { Data } from "ws";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
|
||||
interface WebSocketClientOptions {
|
||||
token: string | null;
|
||||
onOpen?: (event: Event) => void;
|
||||
onMessage?: (event: MessageEvent<Data>) => void;
|
||||
onError?: (event: Event) => void;
|
||||
onClose?: (event: Event) => void;
|
||||
}
|
||||
|
||||
interface WebSocketContextType {
|
||||
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
||||
start: (options?: WebSocketClientOptions) => void;
|
||||
stop: () => void;
|
||||
setRuntimeIsInitialized: () => void;
|
||||
runtimeActive: boolean;
|
||||
isConnected: boolean;
|
||||
events: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const SocketContext = React.createContext<WebSocketContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SocketProvider({ children }: SocketProviderProps) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [runtimeActive, setRuntimeActive] = React.useState(false);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const setRuntimeIsInitialized = () => {
|
||||
setRuntimeActive(true);
|
||||
};
|
||||
|
||||
const start = React.useCallback((options?: WebSocketClientOptions): void => {
|
||||
if (wsRef.current) {
|
||||
EventLogger.warning(
|
||||
"WebSocket connection is already established, but a new one is starting anyways.",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
|
||||
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
|
||||
|
||||
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
"openhands",
|
||||
sessionToken,
|
||||
ghToken,
|
||||
]);
|
||||
|
||||
ws.addEventListener("open", (event) => {
|
||||
setIsConnected(true);
|
||||
options?.onOpen?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
EventLogger.message(event);
|
||||
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
|
||||
options?.onMessage?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
options?.onError?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
EventLogger.event(event, "SOCKET CLOSE");
|
||||
|
||||
setIsConnected(false);
|
||||
setRuntimeActive(false);
|
||||
wsRef.current = null;
|
||||
options?.onClose?.(event);
|
||||
});
|
||||
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
const stop = React.useCallback((): void => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const send = React.useCallback(
|
||||
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
|
||||
wsRef.current.send(data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
}),
|
||||
[
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useSocket() {
|
||||
const context = React.useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SocketProvider, useSocket };
|
||||
175
frontend/src/context/ws-client-provider.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import AgentState from "#/types/AgentState";
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
STOPPED,
|
||||
OPENING,
|
||||
ACTIVE,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
interface UseWsClient {
|
||||
status: WsClientProviderStatus;
|
||||
events: Record<string, unknown>[];
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const WsClientContext = React.createContext<UseWsClient>({
|
||||
status: WsClientProviderStatus.STOPPED,
|
||||
events: [],
|
||||
send: () => {
|
||||
throw new Error("not connected");
|
||||
},
|
||||
});
|
||||
|
||||
interface WsClientProviderProps {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
ghToken: string | null;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
enabled,
|
||||
token,
|
||||
ghToken,
|
||||
settings,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const tokenRef = React.useRef<string | null>(token);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
wsRef.current.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
const initEvent = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
send(initEvent);
|
||||
}
|
||||
|
||||
function handleMessage(messageEvent: MessageEvent) {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
if (event.extras?.agent_state === AgentState.INIT) {
|
||||
setStatus(WsClientProviderStatus.ACTIVE);
|
||||
}
|
||||
if (
|
||||
status !== WsClientProviderStatus.ACTIVE &&
|
||||
event?.observation === "error"
|
||||
) {
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setEvents([]);
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
function handleError(event: Event) {
|
||||
posthog.capture("socket_error");
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
// Connect websocket
|
||||
React.useEffect(() => {
|
||||
let ws = wsRef.current;
|
||||
|
||||
// If disabled close any existing websockets...
|
||||
if (!enabled) {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// If there is no websocket or the tokens have changed or the current websocket is closed,
|
||||
// create a new one
|
||||
if (
|
||||
!ws ||
|
||||
(tokenRef.current && token !== tokenRef.current) ||
|
||||
ghToken !== ghTokenRef.current ||
|
||||
ws.readyState === WebSocket.CLOSED ||
|
||||
ws.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
ws?.close();
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
"openhands",
|
||||
token || "NO_JWT",
|
||||
ghToken || "NO_GITHUB",
|
||||
]);
|
||||
}
|
||||
ws.addEventListener("open", handleOpen);
|
||||
ws.addEventListener("message", handleMessage);
|
||||
ws.addEventListener("error", handleError);
|
||||
ws.addEventListener("close", handleClose);
|
||||
wsRef.current = ws;
|
||||
tokenRef.current = token;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener("open", handleOpen);
|
||||
ws.removeEventListener("message", handleMessage);
|
||||
ws.removeEventListener("error", handleError);
|
||||
ws.removeEventListener("close", handleClose);
|
||||
};
|
||||
}, [enabled, token, ghToken]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually closing the socket and cancel the operation if the component gets remounted.
|
||||
React.useEffect(() => {
|
||||
const timeout = closeRef.current;
|
||||
if (timeout != null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
closeRef.current = setTimeout(() => {
|
||||
wsRef.current?.close();
|
||||
}, 100);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<UseWsClient>(
|
||||
() => ({
|
||||
status,
|
||||
events,
|
||||
send,
|
||||
}),
|
||||
[status, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsClientContext.Provider value={value}>
|
||||
{children}
|
||||
</WsClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWsClient() {
|
||||
const context = React.useContext(WsClientContext);
|
||||
return context;
|
||||
}
|
||||
@@ -10,16 +10,19 @@ import React, { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { SocketProvider } from "./context/socket";
|
||||
import "./i18n";
|
||||
import store from "./store";
|
||||
|
||||
function PosthogInit() {
|
||||
React.useEffect(() => {
|
||||
posthog.init("phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA", {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
fetch("/config.json")
|
||||
.then((response) => response.json())
|
||||
.then((config) => {
|
||||
posthog.init(config.POSTHOG_CLIENT_KEY, {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
@@ -43,12 +46,10 @@ prepareApp().then(() =>
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<Provider store={store}>
|
||||
<RemixBrowser />
|
||||
<PosthogInit />
|
||||
</Provider>
|
||||
</SocketProvider>
|
||||
<Provider store={store}>
|
||||
<RemixBrowser />
|
||||
<PosthogInit />
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
import { getTerminalCommand } from "#/services/terminalService";
|
||||
import { parseTerminalOutput } from "#/utils/parseTerminalOutput";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
@@ -15,7 +15,7 @@ export const useTerminal = (
|
||||
commands: Command[] = [],
|
||||
secrets: string[] = [],
|
||||
) => {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -535,7 +535,8 @@
|
||||
"pt": "Socket não inicializado",
|
||||
"ko-KR": "소켓이 초기화되지 않았습니다",
|
||||
"ar": "لم يتم تهيئة Socket",
|
||||
"tr": "Soket başlatılmadı"
|
||||
"tr": "Soket başlatılmadı",
|
||||
"no": "Socket ikke initialisert"
|
||||
},
|
||||
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
|
||||
"en": "Error uploading file",
|
||||
@@ -548,7 +549,8 @@
|
||||
"pt": "Erro ao fazer upload do arquivo",
|
||||
"ko-KR": "파일 업로드 중 오류 발생",
|
||||
"ar": "خطأ في تحميل الملف",
|
||||
"tr": "Dosya yüklenirken hata oluştu"
|
||||
"tr": "Dosya yüklenirken hata oluştu",
|
||||
"no": "Feil ved opplasting av fil"
|
||||
},
|
||||
"EXPLORER$LABEL_DROP_FILES": {
|
||||
"en": "Drop files here",
|
||||
@@ -557,6 +559,7 @@
|
||||
"zh-TW": "將檔案拖曳至此",
|
||||
"es": "Suelta los archivos aquí",
|
||||
"fr": "Déposez les fichiers ici",
|
||||
"no": "Slipp filer her",
|
||||
"it": "Trascina i file qui",
|
||||
"pt": "Solte os arquivos aqui",
|
||||
"ko-KR": "파일을 여기에 놓으세요",
|
||||
@@ -574,7 +577,8 @@
|
||||
"pt": "Espaço de trabalho",
|
||||
"ko-KR": "작업 공간",
|
||||
"ar": "مساحة العمل",
|
||||
"tr": "Çalışma alanı"
|
||||
"tr": "Çalışma alanı",
|
||||
"no": "Arbeidsområde"
|
||||
},
|
||||
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
|
||||
"en": "No files in workspace",
|
||||
@@ -587,7 +591,8 @@
|
||||
"pt": "Nenhum arquivo no espaço de trabalho",
|
||||
"ko-KR": "작업 공간에 파일이 없습니다",
|
||||
"ar": "لا توجد ملفات في مساحة العمل",
|
||||
"tr": "Çalışma alanında dosya yok"
|
||||
"tr": "Çalışma alanında dosya yok",
|
||||
"no": "Ingen filer i arbeidsområdet"
|
||||
},
|
||||
"EXPLORER$LOADING_WORKSPACE_MESSAGE": {
|
||||
"en": "Loading workspace...",
|
||||
@@ -600,7 +605,8 @@
|
||||
"pt": "Carregando espaço de trabalho...",
|
||||
"ko-KR": "작업 공간 로딩 중...",
|
||||
"ar": "جارٍ تحميل مساحة العمل...",
|
||||
"tr": "Çalışma alanı yükleniyor..."
|
||||
"tr": "Çalışma alanı yükleniyor...",
|
||||
"no": "Laster arbeidsområde..."
|
||||
},
|
||||
"EXPLORER$REFRESH_ERROR_MESSAGE": {
|
||||
"en": "Error refreshing workspace",
|
||||
@@ -613,7 +619,8 @@
|
||||
"pt": "Erro ao atualizar o espaço de trabalho",
|
||||
"ko-KR": "작업 공간 새로 고침 오류",
|
||||
"ar": "خطأ في تحديث مساحة العمل",
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu"
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu",
|
||||
"no": "Feil ved oppdatering av arbeidsområde"
|
||||
},
|
||||
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
|
||||
"en": "Successfully uploaded {{count}} file(s)",
|
||||
@@ -626,7 +633,8 @@
|
||||
"pt": "{{count}} arquivo(s) carregado(s) com sucesso",
|
||||
"ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다",
|
||||
"ar": "تم تحميل {{count}} ملف (ملفات) بنجاح",
|
||||
"tr": "{{count}} dosya başarıyla yüklendi"
|
||||
"tr": "{{count}} dosya başarıyla yüklendi",
|
||||
"no": "Lastet opp {{count}} fil(er) vellykket"
|
||||
},
|
||||
"EXPLORER$NO_FILES_UPLOADED_MESSAGE": {
|
||||
"en": "No files were uploaded",
|
||||
@@ -639,7 +647,8 @@
|
||||
"pt": "Nenhum arquivo foi carregado",
|
||||
"ko-KR": "업로드된 파일이 없습니다",
|
||||
"ar": "لم يتم تحميل أي ملفات",
|
||||
"tr": "Hiçbir dosya yüklenmedi"
|
||||
"tr": "Hiçbir dosya yüklenmedi",
|
||||
"no": "Ingen filer ble lastet opp"
|
||||
},
|
||||
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
|
||||
"en": "{{count}} file(s) were skipped during upload",
|
||||
@@ -652,7 +661,8 @@
|
||||
"pt": "{{count}} arquivo(s) foram ignorados durante o upload",
|
||||
"ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다",
|
||||
"ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل",
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı"
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı",
|
||||
"no": "{{count}} fil(er) ble hoppet over under opplasting"
|
||||
},
|
||||
"EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": {
|
||||
"en": "Unexpected response structure from server",
|
||||
@@ -665,7 +675,8 @@
|
||||
"pt": "Estrutura de resposta inesperada do servidor",
|
||||
"ko-KR": "서버로부터 예상치 못한 응답 구조",
|
||||
"ar": "بنية استجابة غير متوقعة من الخادم",
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı"
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı",
|
||||
"no": "Uventet responsstruktur fra serveren"
|
||||
},
|
||||
"LOAD_SESSION$MODAL_TITLE": {
|
||||
"en": "Return to existing session?",
|
||||
@@ -798,7 +809,326 @@
|
||||
"tr": "İptal"
|
||||
},
|
||||
"FEEDBACK$EMAIL_PLACEHOLDER": {
|
||||
"en": "Enter your email address."
|
||||
"en": "Enter your email address",
|
||||
"es": "Ingresa tu correo electrónico",
|
||||
"zh-CN": "输入您的电子邮件地址",
|
||||
"zh-TW": "輸入您的電子郵件地址",
|
||||
"ko-KR": "이메일 주소를 입력하세요",
|
||||
"no": "Skriv inn din e-postadresse",
|
||||
"ar": "أدخل عنوان بريدك الإلكتروني",
|
||||
"de": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||
"fr": "Entrez votre adresse e-mail",
|
||||
"it": "Inserisci il tuo indirizzo email",
|
||||
"pt": "Digite seu endereço de e-mail",
|
||||
"tr": "E-posta adresinizi girin"
|
||||
},
|
||||
"FEEDBACK$PASSWORD_COPIED_MESSAGE": {
|
||||
"en": "Password copied to clipboard.",
|
||||
"es": "Contraseña copiada al portapapeles.",
|
||||
"zh-CN": "密码已复制到剪贴板。",
|
||||
"zh-TW": "密碼已複製到剪貼板。",
|
||||
"ko-KR": "비밀번호가 클립보드에 복사되었습니다.",
|
||||
"no": "Passord kopiert til utklippstavlen.",
|
||||
"ar": "تم نسخ كلمة المرور إلى الحافظة.",
|
||||
"de": "Passwort in die Zwischenablage kopiert.",
|
||||
"fr": "Mot de passe copié dans le presse-papiers.",
|
||||
"it": "Password copiata negli appunti.",
|
||||
"pt": "Senha copiada para a área de transferência.",
|
||||
"tr": "Parola panoya kopyalandı."
|
||||
},
|
||||
"FEEDBACK$GO_TO_FEEDBACK": {
|
||||
"en": "Go to shared feedback",
|
||||
"es": "Ir a feedback compartido",
|
||||
"zh-CN": "转到共享反馈",
|
||||
"zh-TW": "前往共享反饋",
|
||||
"ko-KR": "공유된 피드백으로 이동",
|
||||
"no": "Gå til delt tilbakemelding",
|
||||
"ar": "الذهاب إلى التعليقات المشتركة",
|
||||
"de": "Zum geteilten Feedback gehen",
|
||||
"fr": "Aller aux commentaires partagés",
|
||||
"it": "Vai al feedback condiviso",
|
||||
"pt": "Ir para feedback compartilhado",
|
||||
"tr": "Paylaşılan geri bildirimlere git"
|
||||
},
|
||||
"FEEDBACK$PASSWORD": {
|
||||
"en": "Password:",
|
||||
"es": "Contraseña:",
|
||||
"zh-CN": "密码:",
|
||||
"zh-TW": "密碼:",
|
||||
"ko-KR": "비밀번호:",
|
||||
"no": "Passord:",
|
||||
"ar": "كلمة المرور:",
|
||||
"de": "Passwort:",
|
||||
"fr": "Mot de passe :",
|
||||
"it": "Password:",
|
||||
"pt": "Senha:",
|
||||
"tr": "Parola:"
|
||||
},
|
||||
"FEEDBACK$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Invalid email format",
|
||||
"es": "Formato de correo inválido",
|
||||
"zh-CN": "无效的电子邮件格式",
|
||||
"zh-TW": "無效的電子郵件格式",
|
||||
"ko-KR": "잘못된 이메일 형식",
|
||||
"no": "Ugyldig e-postformat",
|
||||
"ar": "تنسيق البريد الإلكتروني غير صالح",
|
||||
"de": "Ungültiges E-Mail-Format",
|
||||
"fr": "Format d'e-mail invalide",
|
||||
"it": "Formato email non valido",
|
||||
"pt": "Formato de e-mail inválido",
|
||||
"tr": "Geçersiz e-posta biçimi"
|
||||
},
|
||||
"FEEDBACK$FAILED_TO_SHARE": {
|
||||
"en": "Failed to share, please contact the developers:",
|
||||
"es": "Error al compartir, por favor contacta con los desarrolladores:",
|
||||
"zh-CN": "分享失败,请联系开发人员:",
|
||||
"zh-TW": "分享失敗,請聯繫開發人員:",
|
||||
"ko-KR": "공유 실패, 개발자에게 문의하세요:",
|
||||
"no": "Deling mislyktes, vennligst kontakt utviklerne:",
|
||||
"ar": "فشل المشاركة، يرجى الاتصال بالمطورين:",
|
||||
"de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:",
|
||||
"fr": "Échec du partage, veuillez contacter les développeurs :",
|
||||
"it": "Condivisione fallita, contattare gli sviluppatori:",
|
||||
"pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:",
|
||||
"tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:"
|
||||
},
|
||||
"FEEDBACK$COPY_LABEL": {
|
||||
"en": "Copy",
|
||||
"es": "Copiar",
|
||||
"zh-CN": "复制",
|
||||
"zh-TW": "複製",
|
||||
"ko-KR": "복사",
|
||||
"no": "Kopier",
|
||||
"ar": "نسخ",
|
||||
"de": "Kopieren",
|
||||
"fr": "Copier",
|
||||
"it": "Copia",
|
||||
"pt": "Copiar",
|
||||
"tr": "Kopyala"
|
||||
},
|
||||
"FEEDBACK$SHARING_SETTINGS_LABEL": {
|
||||
"en": "Sharing settings",
|
||||
"es": "Configuración de compartir",
|
||||
"zh-CN": "共享设置",
|
||||
"zh-TW": "共享設定",
|
||||
"ko-KR": "공유 설정",
|
||||
"no": "Delingsinnstillinger",
|
||||
"ar": "إعدادات المشاركة",
|
||||
"de": "Freigabeeinstellungen",
|
||||
"fr": "Paramètres de partage",
|
||||
"it": "Impostazioni di condivisione",
|
||||
"pt": "Configurações de compartilhamento",
|
||||
"tr": "Paylaşım ayarları"
|
||||
},
|
||||
"SECURITY$UNKNOWN_ANALYZER_LABEL":{
|
||||
"en": "Unknown security analyzer chosen",
|
||||
"es": "Analizador de seguridad desconocido",
|
||||
"zh-CN": "选择了未知的安全分析器",
|
||||
"zh-TW": "選擇了未知的安全分析器",
|
||||
"ko-KR": "알 수 없는 보안 분석기가 선택되었습니다",
|
||||
"no": "Ukjent sikkerhetsanalysator valgt",
|
||||
"ar": "تم اختيار محلل أمان غير معروف",
|
||||
"de": "Unbekannter Sicherheitsanalysator ausgewählt",
|
||||
"fr": "Analyseur de sécurité inconnu choisi",
|
||||
"it": "Analizzatore di sicurezza sconosciuto selezionato",
|
||||
"pt": "Analisador de segurança desconhecido escolhido",
|
||||
"tr": "Bilinmeyen güvenlik analizörü seçildi"
|
||||
},
|
||||
"INVARIANT$UPDATE_POLICY_LABEL": {
|
||||
"en": "Update Policy",
|
||||
"es": "Actualizar política",
|
||||
"zh-CN": "更新策略",
|
||||
"zh-TW": "更新策略",
|
||||
"ko-KR": "정책 업데이트",
|
||||
"no": "Oppdater policy",
|
||||
"ar": "تحديث السياسة",
|
||||
"de": "Richtlinie aktualisieren",
|
||||
"fr": "Mettre à jour la politique",
|
||||
"it": "Aggiorna policy",
|
||||
"pt": "Atualizar política",
|
||||
"tr": "İlkeyi güncelle"
|
||||
},
|
||||
"INVARIANT$UPDATE_SETTINGS_LABEL": {
|
||||
"en": "Update Settings",
|
||||
"es": "Actualizar configuración",
|
||||
"zh-CN": "更新设置",
|
||||
"zh-TW": "更新設定",
|
||||
"ko-KR": "설정 업데이트",
|
||||
"no": "Oppdater innstillinger",
|
||||
"ar": "تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisieren",
|
||||
"fr": "Mettre à jour les paramètres",
|
||||
"it": "Aggiorna impostazioni",
|
||||
"pt": "Atualizar configurações",
|
||||
"tr": "Ayarları güncelle"
|
||||
},
|
||||
"INVARIANT$SETTINGS_LABEL": {
|
||||
"en": "Settings",
|
||||
"es": "Configuración",
|
||||
"zh-CN": "设置",
|
||||
"zh-TW": "設定",
|
||||
"ko-KR": "설정",
|
||||
"no": "Innstillinger",
|
||||
"ar": "الإعدادات",
|
||||
"de": "Einstellungen",
|
||||
"fr": "Paramètres",
|
||||
"it": "Impostazioni",
|
||||
"pt": "Configurações",
|
||||
"tr": "Ayarlar"
|
||||
},
|
||||
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
|
||||
"en": "Ask for user confirmation on risk severity:",
|
||||
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:",
|
||||
"zh-CN": "询问用户确认风险等级:",
|
||||
"zh-TW": "詢問用戶確認風險等級:",
|
||||
"ko-KR": "위험 심각도에 대한 사용자 확인 요청:",
|
||||
"no": "Be om brukerbekreftelse på risikoalvorlighet:",
|
||||
"ar": "اطلب تأكيد المستخدم على مستوى الخطورة:",
|
||||
"de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:",
|
||||
"fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :",
|
||||
"it": "Chiedi conferma all'utente sulla gravità del rischio:",
|
||||
"pt": "Solicitar confirmação do usuário sobre a gravidade do risco:",
|
||||
"tr": "Risk şiddeti için kullanıcı onayı iste:"
|
||||
},
|
||||
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
|
||||
"en": "Don't ask for confirmation",
|
||||
"es": "No solicitar confirmación",
|
||||
"zh-CN": "不要请求确认",
|
||||
"zh-TW": "不要請求確認",
|
||||
"ko-KR": "확인 요청하지 않음",
|
||||
"no": "Ikke spør om bekreftelse",
|
||||
"ar": "لا تطلب التأكيد",
|
||||
"de": "Nicht nach Bestätigung fragen",
|
||||
"fr": "Ne pas demander de confirmation",
|
||||
"it": "Non chiedere conferma",
|
||||
"pt": "Não solicitar confirmação",
|
||||
"tr": "Onay isteme"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_LABEL": {
|
||||
"en": "Invariant Analyzer",
|
||||
"es": "Analizador de invariantes",
|
||||
"zh-CN": "不变量分析器",
|
||||
"zh-TW": "不變量分析器",
|
||||
"ko-KR": "불변성 분석기",
|
||||
"no": "Invariant-analysator",
|
||||
"ar": "محلل الثوابت",
|
||||
"de": "Invarianten-Analysator",
|
||||
"fr": "Analyseur d'invariants",
|
||||
"it": "Analizzatore di invarianti",
|
||||
"pt": "Analisador de invariantes",
|
||||
"tr": "Değişmez Analizörü"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
|
||||
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
|
||||
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.",
|
||||
"zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。",
|
||||
"zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。",
|
||||
"ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.",
|
||||
"no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.",
|
||||
"ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.",
|
||||
"de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.",
|
||||
"fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.",
|
||||
"it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.",
|
||||
"pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.",
|
||||
"tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler."
|
||||
},
|
||||
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
|
||||
"en": "Click to learn more",
|
||||
"es": "Clic para aprender más",
|
||||
"zh-CN": "点击了解更多",
|
||||
"zh-TW": "點擊了解更多",
|
||||
"ko-KR": "자세히 알아보기",
|
||||
"no": "Klikk for å lære mer",
|
||||
"ar": "انقر لمعرفة المزيد",
|
||||
"de": "Klicken Sie, um mehr zu erfahren",
|
||||
"fr": "Cliquez pour en savoir plus",
|
||||
"it": "Clicca per saperne di più",
|
||||
"pt": "Clique para saber mais",
|
||||
"tr": "Daha fazla bilgi için tıklayın"
|
||||
},
|
||||
"INVARIANT$POLICY_LABEL": {
|
||||
"en": "Policy",
|
||||
"es": "Política",
|
||||
"zh-CN": "策略",
|
||||
"zh-TW": "策略",
|
||||
"ko-KR": "정책",
|
||||
"no": "Policy",
|
||||
"ar": "السياسة",
|
||||
"de": "Richtlinie",
|
||||
"fr": "Politique",
|
||||
"it": "Policy",
|
||||
"pt": "Política",
|
||||
"tr": "İlke"
|
||||
},
|
||||
"INVARIANT$LOG_LABEL": {
|
||||
"en": "Logs",
|
||||
"es": "Logs",
|
||||
"zh-CN": "日志",
|
||||
"zh-TW": "日誌",
|
||||
"ko-KR": "로그",
|
||||
"no": "Logger",
|
||||
"ar": "السجلات",
|
||||
"de": "Protokolle",
|
||||
"fr": "Journaux",
|
||||
"it": "Log",
|
||||
"pt": "Logs",
|
||||
"tr": "Günlükler"
|
||||
},
|
||||
"INVARIANT$EXPORT_TRACE_LABEL": {
|
||||
"en": "Export Trace",
|
||||
"es": "Exportar traza",
|
||||
"zh-CN": "导出跟踪",
|
||||
"zh-TW": "匯出追蹤",
|
||||
"ko-KR": "추적 내보내기",
|
||||
"no": "Eksporter sporing",
|
||||
"ar": "تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportieren",
|
||||
"fr": "Exporter la trace",
|
||||
"it": "Esporta traccia",
|
||||
"pt": "Exportar rastreamento",
|
||||
"tr": "İzlemeyi dışa aktar"
|
||||
},
|
||||
"INVARIANT$TRACE_EXPORTED_MESSAGE": {
|
||||
"en": "Trace exported",
|
||||
"es": "Traza exportada",
|
||||
"zh-CN": "跟踪已导出",
|
||||
"zh-TW": "追蹤已匯出",
|
||||
"ko-KR": "추적 내보내기 완료",
|
||||
"no": "Sporing eksportert",
|
||||
"ar": "تم تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportiert",
|
||||
"fr": "Trace exportée",
|
||||
"it": "Traccia esportata",
|
||||
"pt": "Rastreamento exportado",
|
||||
"tr": "İzleme dışa aktarıldı"
|
||||
},
|
||||
"INVARIANT$POLICY_UPDATED_MESSAGE": {
|
||||
"en": "Policy updated",
|
||||
"es": "Política actualizada",
|
||||
"zh-CN": "策略已更新",
|
||||
"zh-TW": "策略已更新",
|
||||
"ko-KR": "정책이 업데이트되었습니다",
|
||||
"no": "Policy oppdatert",
|
||||
"ar": "تم تحديث السياسة",
|
||||
"de": "Richtlinie aktualisiert",
|
||||
"fr": "Politique mise à jour",
|
||||
"it": "Policy aggiornata",
|
||||
"pt": "Política atualizada",
|
||||
"tr": "İlke güncellendi"
|
||||
},
|
||||
"INVARIANT$SETTINGS_UPDATED_MESSAGE": {
|
||||
"en": "Settings updated",
|
||||
"es": "Configuración actualizada",
|
||||
"zh-CN": "设置已更新",
|
||||
"zh-TW": "設定已更新",
|
||||
"ko-KR": "설정이 업데이트되었습니다",
|
||||
"no": "Innstillinger oppdatert",
|
||||
"ar": "تم تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisiert",
|
||||
"fr": "Paramètres mis à jour",
|
||||
"it": "Impostazioni aggiornate",
|
||||
"pt": "Configurações atualizadas",
|
||||
"tr": "Ayarlar güncellendi"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Starting up!",
|
||||
@@ -1187,7 +1517,8 @@
|
||||
"pt": "Conversa de chat",
|
||||
"es": "Conversación de chat",
|
||||
"ar": "محادثة تلقيم",
|
||||
"fr": "Conversation de chat"
|
||||
"fr": "Conversation de chat",
|
||||
"tr": "Sohbet Konuşması"
|
||||
},
|
||||
"CHAT_INTERFACE$UNKNOWN_SENDER": {
|
||||
"en": "Unknown",
|
||||
@@ -1442,10 +1773,12 @@
|
||||
"tr": "Özel"
|
||||
},
|
||||
"ERROR_MESSAGE$SHOW_DETAILS": {
|
||||
"en": "Show details"
|
||||
"en": "Show details",
|
||||
"es": "Mostrar detalles"
|
||||
},
|
||||
"ERROR_MESSAGE$HIDE_DETAILS": {
|
||||
"en": "Hide details"
|
||||
"en": "Hide details",
|
||||
"es": "Ocultar detalles"
|
||||
},
|
||||
"STATUS$STARTING_RUNTIME": {
|
||||
"en": "Starting Runtime...",
|
||||
@@ -1517,8 +1850,137 @@
|
||||
"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": "Cerrar"
|
||||
},
|
||||
"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"
|
||||
"en": "Error authenticating with the LLM provider. Please check your API key",
|
||||
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
|
||||
},
|
||||
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
|
||||
"en": "There was an error while connecting to the runtime. Please refresh the page."
|
||||
@@ -1528,5 +1990,17 @@
|
||||
},
|
||||
"AGENT_ERROR$ACTION_TIMEOUT": {
|
||||
"en": "Action timed out."
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
|
||||
"en": "Connect to GitHub",
|
||||
"es": "Conectar a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": {
|
||||
"en": "Push to GitHub",
|
||||
"es": "Subir a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
|
||||
"en": "Download as .zip",
|
||||
"es": "Descargar como .zip"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 662 B After Width: | Height: | Size: 662 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 387 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 378 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 319 B After Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 552 B After Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 924 B After Width: | Height: | Size: 924 B |
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 649 B After Width: | Height: | Size: 649 B |
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 811 B After Width: | Height: | Size: 811 B |
@@ -71,8 +71,6 @@ const openHandsHandlers = [
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("https://api.github.com/user/repos", async ({ request }) => {
|
||||
if (import.meta.env.MODE !== "test") await delay(3500);
|
||||
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
@@ -92,6 +90,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);
|
||||
|
||||
@@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
images_urls: [],
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import BuildIt from "#/assets/build-it.svg?react";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
|
||||
export function HeroHeading() {
|
||||
return (
|
||||
|
||||
@@ -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,9 @@ 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",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
@@ -112,6 +80,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}
|
||||
|
||||
@@ -29,6 +29,10 @@ function CodeEditorCompoonent({
|
||||
if (selectedPath && value) modifyFileContent(selectedPath, value);
|
||||
};
|
||||
|
||||
const isBase64Image = (content: string) => content.startsWith("data:image/");
|
||||
const isPDF = (content: string) => content.startsWith("data:application/pdf");
|
||||
const isVideo = (content: string) => content.startsWith("data:video/");
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleSave = async (event: KeyboardEvent) => {
|
||||
if (selectedPath && event.metaKey && event.key === "s") {
|
||||
@@ -62,16 +66,40 @@ function CodeEditorCompoonent({
|
||||
);
|
||||
}
|
||||
|
||||
const fileContent = modifiedFiles[selectedPath] || files[selectedPath];
|
||||
|
||||
if (isBase64Image(fileContent)) {
|
||||
return (
|
||||
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
|
||||
<img src={fileContent} alt={selectedPath} className="object-contain" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPDF(fileContent)) {
|
||||
return (
|
||||
<iframe
|
||||
src={fileContent}
|
||||
title={selectedPath}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo(fileContent)) {
|
||||
return (
|
||||
<video controls src={fileContent} width="100%" height="100%">
|
||||
<track kind="captions" label="English captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Editor
|
||||
data-testid="code-editor"
|
||||
path={selectedPath ?? undefined}
|
||||
defaultValue=""
|
||||
value={
|
||||
selectedPath
|
||||
? modifiedFiles[selectedPath] || files[selectedPath]
|
||||
: undefined
|
||||
}
|
||||
value={selectedPath ? fileContent : undefined}
|
||||
onMount={onMount}
|
||||
onChange={handleEditorChange}
|
||||
options={{ readOnly: isReadOnly }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,70 +2,29 @@ import { useDisclosure } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import {
|
||||
Outlet,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
json,
|
||||
ClientActionFunctionArgs,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import WebSocket from "ws";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { getSettings } from "#/services/settings";
|
||||
import Security from "../components/modals/security/Security";
|
||||
import { Controls } from "#/components/controls";
|
||||
import store, { RootState } from "#/store";
|
||||
import store from "#/store";
|
||||
import { Container } from "#/components/container";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import {
|
||||
addErrorMessage,
|
||||
addUserMessage,
|
||||
clearMessages,
|
||||
} from "#/state/chatSlice";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import {
|
||||
getGitHubTokenCommand,
|
||||
getCloneRepoCommand,
|
||||
} from "#/services/terminalService";
|
||||
import { clearMessages } from "#/state/chatSlice";
|
||||
import { clearTerminal } from "#/state/commandSlice";
|
||||
import { useEffectOnce } from "#/utils/use-effect-once";
|
||||
import CodeIcon from "#/assets/code.svg?react";
|
||||
import GlobeIcon from "#/assets/globe.svg?react";
|
||||
import ListIcon from "#/assets/list-type-number.svg?react";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import CodeIcon from "#/icons/code.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ListIcon from "#/icons/list-type-number.svg?react";
|
||||
import { clearInitialQuery } from "#/state/initial-query-slice";
|
||||
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
||||
import { clearJupyter } from "#/state/jupyterSlice";
|
||||
import { FilesProvider } from "#/context/files";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
const isErrorObservation = (data: object): data is ErrorObservation =>
|
||||
"observation" in data && data.observation === "error";
|
||||
|
||||
const isAgentStateChange = (
|
||||
data: object,
|
||||
): data is { extras: { agent_state: AgentState } } =>
|
||||
"extras" in data &&
|
||||
data.extras instanceof Object &&
|
||||
"agent_state" in data.extras;
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "#/components/event-handler";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
@@ -114,175 +73,27 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
console.log("render app");
|
||||
const dispatch = useDispatch();
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
|
||||
const { settings, token, ghToken, repo, q, lastCommit } =
|
||||
const { settings, token, ghToken, lastCommit } =
|
||||
useLoaderData<typeof clientLoader>();
|
||||
const fetcher = useFetcher();
|
||||
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
|
||||
const secrets = React.useMemo(
|
||||
() => [ghToken, token].filter((secret) => secret !== null),
|
||||
[ghToken, token],
|
||||
);
|
||||
|
||||
// To avoid re-rendering the component when the user object changes, we memoize the user ID.
|
||||
// We use this to ensure the github token is valid before exporting it to the terminal.
|
||||
const userId = React.useMemo(() => {
|
||||
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
|
||||
return null;
|
||||
}, [data?.user]);
|
||||
|
||||
const Terminal = React.useMemo(
|
||||
() => React.lazy(() => import("../components/terminal/Terminal")),
|
||||
[],
|
||||
);
|
||||
|
||||
const addIntialQueryToChat = (
|
||||
query: string,
|
||||
base64Files: string[],
|
||||
timestamp = new Date().toISOString(),
|
||||
) => {
|
||||
dispatch(
|
||||
addUserMessage({
|
||||
content: query,
|
||||
imageUrls: base64Files,
|
||||
timestamp,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const sendInitialQuery = (query: string, base64Files: string[]) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
send(createChatMessage(query, base64Files, timestamp));
|
||||
};
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
const initEvent = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
send(JSON.stringify(initEvent));
|
||||
|
||||
// display query in UI, but don't send it to the server
|
||||
if (q) addIntialQueryToChat(q, files);
|
||||
}, [settings]);
|
||||
|
||||
const handleMessage = React.useCallback(
|
||||
(message: MessageEvent<WebSocket.Data>) => {
|
||||
// set token received from the server
|
||||
const parsed = JSON.parse(message.data.toString());
|
||||
if ("token" in parsed) {
|
||||
fetcher.submit({ token: parsed.token }, { method: "post" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerError(parsed)) {
|
||||
if (parsed.error_code === 401) {
|
||||
toast.error("Session expired.");
|
||||
fetcher.submit({}, { method: "POST", action: "/end-session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed.error === "string") {
|
||||
toast.error(parsed.error);
|
||||
} else {
|
||||
toast.error(parsed.message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (isErrorObservation(parsed)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
id: parsed.extras?.error_id,
|
||||
message: parsed.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAssistantMessage(message.data.toString());
|
||||
|
||||
// handle first time connection
|
||||
if (
|
||||
isAgentStateChange(parsed) &&
|
||||
parsed.extras.agent_state === AgentState.INIT
|
||||
) {
|
||||
setRuntimeIsInitialized();
|
||||
|
||||
// handle new session
|
||||
if (!token) {
|
||||
let additionalInfo = "";
|
||||
if (ghToken && repo) {
|
||||
send(getCloneRepoCommand(ghToken, repo));
|
||||
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
|
||||
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
|
||||
}
|
||||
// if there's an uploaded project zip, add it to the chat
|
||||
else if (importedProjectZip) {
|
||||
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
|
||||
}
|
||||
|
||||
if (q) {
|
||||
if (additionalInfo) {
|
||||
sendInitialQuery(`${q}\n\n[${additionalInfo}]`, files);
|
||||
} else {
|
||||
sendInitialQuery(q, files);
|
||||
}
|
||||
dispatch(clearFiles()); // reset selected files
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, ghToken, repo, q, files],
|
||||
);
|
||||
|
||||
const startSocketConnection = React.useCallback(() => {
|
||||
start({
|
||||
token,
|
||||
onOpen: handleOpen,
|
||||
onMessage: handleMessage,
|
||||
});
|
||||
}, [token, handleOpen, handleMessage]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
// clear and restart the socket connection
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
startSocketConnection();
|
||||
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && ghToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(ghToken));
|
||||
}
|
||||
}, [userId, ghToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
// upload files action
|
||||
try {
|
||||
const blob = base64ToBlob(importedProjectZip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles([file]);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload project files.");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
|
||||
const {
|
||||
isOpen: securityModalIsOpen,
|
||||
onOpen: onSecurityModalOpen,
|
||||
@@ -290,53 +101,62 @@ function App() {
|
||||
} = useDisclosure();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-[390px] max-h-full">
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
<WsClientProvider
|
||||
enabled
|
||||
token={token}
|
||||
ghToken={ghToken}
|
||||
settings={settings}
|
||||
>
|
||||
<EventHandler>
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-[390px] max-h-full relative">
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
|
||||
<div className="flex flex-col grow gap-3">
|
||||
<Container
|
||||
className="h-2/3"
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
{
|
||||
label: "Browser",
|
||||
to: "browser",
|
||||
icon: <GlobeIcon />,
|
||||
isBeta: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FilesProvider>
|
||||
<Outlet />
|
||||
</FilesProvider>
|
||||
</Container>
|
||||
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
||||
* that it loads only in the client-side. */}
|
||||
<Container className="h-1/3 overflow-scroll" label="Terminal">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<Terminal secrets={secrets} />
|
||||
</React.Suspense>
|
||||
</Container>
|
||||
<div className="flex flex-col grow gap-3">
|
||||
<Container
|
||||
className="h-2/3"
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
{
|
||||
label: "Browser",
|
||||
to: "browser",
|
||||
icon: <GlobeIcon />,
|
||||
isBeta: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FilesProvider>
|
||||
<Outlet />
|
||||
</FilesProvider>
|
||||
</Container>
|
||||
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
||||
* that it loads only in the client-side. */}
|
||||
<Container className="h-1/3 overflow-scroll" label="Terminal">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<Terminal secrets={secrets} />
|
||||
</React.Suspense>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[60px]">
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings.SECURITY_ANALYZER}
|
||||
lastCommitData={lastCommit}
|
||||
/>
|
||||
</div>
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[60px]">
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings.SECURITY_ANALYZER}
|
||||
lastCommitData={lastCommit}
|
||||
/>
|
||||
</div>
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
</div>
|
||||
</EventHandler>
|
||||
</WsClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||