mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
99 Commits
enhance-pa
...
fix/locali
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b206c9727 | ||
|
|
b100bb51c9 | ||
|
|
ec03ce1ca0 | ||
|
|
46157a85d8 | ||
|
|
a691e3148a | ||
|
|
4674e0b77a | ||
|
|
d7d0329d25 | ||
|
|
17853cd5bd | ||
|
|
c992b6d2a0 | ||
|
|
34bf645d64 | ||
|
|
1ae1c16b26 | ||
|
|
5099413729 | ||
|
|
b06a3bdb7c | ||
|
|
a7b234d1f6 | ||
|
|
2c2a721937 | ||
|
|
7abad5844a | ||
|
|
4781e9a424 | ||
|
|
a24d7e636e | ||
|
|
66b95adbc9 | ||
|
|
d617d6842a | ||
|
|
0eb7f956a9 | ||
|
|
d3154c4bae | ||
|
|
04a15b1467 | ||
|
|
b74da7d4c3 | ||
|
|
70ad469fb2 | ||
|
|
a85f6af9c2 | ||
|
|
5e213963dc | ||
|
|
051c579855 | ||
|
|
6d66b8503c | ||
|
|
0fb1a712d5 | ||
|
|
3b6b6a10d8 | ||
|
|
5e6553854e | ||
|
|
e8dba65355 | ||
|
|
7ebc8be7bb | ||
|
|
c827b0dbb8 | ||
|
|
a5207bf8c0 | ||
|
|
2b05d4c320 | ||
|
|
e5fb016388 | ||
|
|
0519f019c1 | ||
|
|
dc569a629c | ||
|
|
c7b71cd092 | ||
|
|
ab15422d77 | ||
|
|
101f40f447 | ||
|
|
0068737636 | ||
|
|
2b6e4c4240 | ||
|
|
255f910cf6 | ||
|
|
b34d555206 | ||
|
|
a892cb0cf3 | ||
|
|
5808e5587f | ||
|
|
5069cb82e8 | ||
|
|
70ce1dd400 | ||
|
|
65abbfa39d | ||
|
|
4a879f22d7 | ||
|
|
d0db3a8a21 | ||
|
|
7afd9ccf93 | ||
|
|
5bea4ab6b7 | ||
|
|
2f819b4f80 | ||
|
|
1284f720ac | ||
|
|
8374d19b08 | ||
|
|
5ebae57add | ||
|
|
d7ac6cbf40 | ||
|
|
8cbbc2331f | ||
|
|
0f359373c0 | ||
|
|
7eebe16d9e | ||
|
|
1fc4c5d856 | ||
|
|
225966e89e | ||
|
|
c66d4fdad8 | ||
|
|
7b71f786bb | ||
|
|
ceac54e767 | ||
|
|
b2a93d9d7f | ||
|
|
00ca066656 | ||
|
|
bf560c2b8f | ||
|
|
fd3531223a | ||
|
|
424c59deb1 | ||
|
|
5f036c7011 | ||
|
|
a044ba85e9 | ||
|
|
55c7cfd293 | ||
|
|
7ba81f952f | ||
|
|
df9f3b2b2b | ||
|
|
7f3dd754c3 | ||
|
|
28f4f8f93d | ||
|
|
68eb51eeab | ||
|
|
ca82a3988f | ||
|
|
4c2039be7e | ||
|
|
94cbf98771 | ||
|
|
326651c339 | ||
|
|
9b2180ec4f | ||
|
|
154d18911f | ||
|
|
05a8c1cf4c | ||
|
|
4f567e390a | ||
|
|
afc5a41aea | ||
|
|
effefa3d56 | ||
|
|
cfb4b400a3 | ||
|
|
fe669bef45 | ||
|
|
429d9100a2 | ||
|
|
ebc075d5ab | ||
|
|
3363c6aeb4 | ||
|
|
de99873f66 | ||
|
|
d5b3e83d66 |
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.46-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -260,6 +260,9 @@ enable_finish = true
|
||||
# length limit
|
||||
enable_history_truncation = true
|
||||
|
||||
# Whether the condensation request tool is enabled
|
||||
enable_condensation_request = false
|
||||
|
||||
[agent.RepoExplorerAgent]
|
||||
# Example: use a cheaper model for RepoExplorerAgent to reduce cost, especially
|
||||
# useful when an agent doesn't demand high quality but uses a lot of tokens
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:22.16.0-bookworm-slim AS frontend-builder
|
||||
FROM node:24.3.0-bookworm-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.46-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -64,7 +64,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--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.46 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -18,42 +18,78 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
You'll need to be sure to set your model, API key, and other settings via environment variables
|
||||
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
|
||||
|
||||
## With Docker
|
||||
### Working with Repositories
|
||||
|
||||
To run OpenHands in Headless mode with Docker:
|
||||
You can specify a repository for OpenHands to work with using `--selected-repo` or the `SANDBOX_SELECTED_REPO` environment variable:
|
||||
|
||||
1. Set the following environment variables in your terminal:
|
||||
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
|
||||
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
|
||||
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
|
||||
|
||||
2. Run the following Docker command:
|
||||
> **Note**: Currently, authentication tokens (GITHUB_TOKEN, GITLAB_TOKEN, or BITBUCKET_TOKEN) are required for all repository operations, including public repositories. This is a known limitation that may be addressed in future versions to allow tokenless access to public repositories.
|
||||
|
||||
```bash
|
||||
# Using command-line argument
|
||||
poetry run python -m openhands.core.main \
|
||||
--selected-repo "owner/repo-name" \
|
||||
-t "analyze the codebase and suggest improvements"
|
||||
|
||||
# Using environment variable
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name"
|
||||
poetry run python -m openhands.core.main -t "fix any linting issues"
|
||||
|
||||
# Authentication tokens are currently required for ALL repository operations (public and private)
|
||||
# This includes GitHub, GitLab, and Bitbucket repositories
|
||||
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
|
||||
poetry run python -m openhands.core.main \
|
||||
--selected-repo "owner/repo-name" \
|
||||
-t "review the security implementation"
|
||||
|
||||
# Using task files instead of inline task
|
||||
echo "Review the README and suggest improvements" > task.txt
|
||||
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
|
||||
```
|
||||
|
||||
## With Docker
|
||||
|
||||
Set environment variables and run the Docker command:
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
|
||||
export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e SANDBOX_SELECTED_REPO=$SANDBOX_SELECTED_REPO \
|
||||
-e GITHUB_TOKEN=$GITHUB_TOKEN \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--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.46 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
## Advanced Headless Configurations
|
||||
## Additional Options
|
||||
|
||||
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
|
||||
Common command-line options:
|
||||
- `-d "/path/to/workspace"` - Set working directory
|
||||
- `-f task.txt` - Load task from file
|
||||
- `-i 50` - Set max iterations
|
||||
- `-b 10.0` - Set budget limit (USD)
|
||||
- `--no-auto-continue` - Interactive mode
|
||||
|
||||
### Additional Logs
|
||||
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
|
||||
|
||||
For the headless mode to log all the agent actions, in the terminal run: `export LOG_ALL_EVENTS=true`
|
||||
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.46
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
|
||||
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.todo("should render an assistant message");
|
||||
|
||||
it.skip("should support code syntax highlighting", () => {
|
||||
it("should support code syntax highlighting", () => {
|
||||
const code = "```js\nconsole.log('Hello, World!')\n```";
|
||||
render(<ChatMessage type="user" message={code} />);
|
||||
|
||||
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an error toast if copying content to clipboard fails", async () => {});
|
||||
|
||||
it("should render a component passed as a prop", () => {
|
||||
function Component() {
|
||||
return <div data-testid="custom-component">Custom Component</div>;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
useParams: vi.fn().mockReturnValue({
|
||||
conversationId: "123",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useHandleRuntimeActive hook
|
||||
vi.mock("#/hooks/use-handle-runtime-active", () => ({
|
||||
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
|
||||
}));
|
||||
|
||||
// Mock the useMicroagentPrompt hook
|
||||
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
|
||||
useMicroagentPrompt: vi.fn().mockReturnValue({
|
||||
data: "Generated prompt",
|
||||
isLoading: false
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useGetMicroagents hook
|
||||
vi.mock("#/hooks/query/use-get-microagents", () => ({
|
||||
useGetMicroagents: vi.fn().mockReturnValue({
|
||||
data: ["file1", "file2"]
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LaunchMicroagentModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const onLaunchMock = vi.fn();
|
||||
const eventId = 12;
|
||||
const conversationId = "123";
|
||||
|
||||
const renderMicroagentModal = (
|
||||
{ isLoading }: { isLoading: boolean } = { isLoading: false },
|
||||
) =>
|
||||
render(
|
||||
<LaunchMicroagentModal
|
||||
onClose={onCloseMock}
|
||||
onLaunch={onLaunchMock}
|
||||
eventId={eventId}
|
||||
selectedRepo="some-repo"
|
||||
isLoading={isLoading}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the launch microagent modal", () => {
|
||||
renderMicroagentModal();
|
||||
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the form fields", () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// inputs
|
||||
screen.getByTestId("query-input");
|
||||
screen.getByTestId("target-input");
|
||||
screen.getByTestId("trigger-input");
|
||||
|
||||
// action buttons
|
||||
screen.getByRole("button", { name: "Launch" });
|
||||
screen.getByRole("button", { name: "Cancel" });
|
||||
});
|
||||
|
||||
it("should call onClose when pressing the cancel button", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the prompt from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const descriptionInput = screen.getByTestId("query-input");
|
||||
expect(descriptionInput).toHaveValue("Generated prompt");
|
||||
});
|
||||
|
||||
it("should display the list of microagent files from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
expect(targetInput).toHaveValue("");
|
||||
|
||||
await userEvent.click(targetInput);
|
||||
|
||||
expect(screen.getByText("file1")).toBeInTheDocument();
|
||||
expect(screen.getByText("file2")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
expect(targetInput).toHaveValue("file1");
|
||||
});
|
||||
|
||||
it("should call onLaunch with the form data", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const triggerInput = screen.getByTestId("trigger-input");
|
||||
await userEvent.type(triggerInput, "trigger1 ");
|
||||
await userEvent.type(triggerInput, "trigger2 ");
|
||||
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
await userEvent.click(targetInput);
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
|
||||
const launchButton = await screen.findByRole("button", { name: "Launch" });
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
|
||||
"trigger1",
|
||||
"trigger2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should disable the launch button if isLoading is true", async () => {
|
||||
renderMicroagentModal({ isLoading: true });
|
||||
|
||||
const launchButton = screen.getByRole("button", { name: "Launch" });
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Messages } from "#/components/features/chat/messages";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
OpenHandsAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderMessages = ({
|
||||
messages,
|
||||
}: {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
}) => {
|
||||
const { rerender, ...rest } = render(
|
||||
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderMessages = (
|
||||
newMessages: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) => {
|
||||
rerender(
|
||||
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
|
||||
);
|
||||
};
|
||||
|
||||
return { ...rest, rerender: rerenderMessages };
|
||||
};
|
||||
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
id: 0,
|
||||
action: "message",
|
||||
source: "agent",
|
||||
message: "Hello, Assistant!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: {
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
thought: "",
|
||||
wait_for_response: false,
|
||||
},
|
||||
};
|
||||
|
||||
const userMessage: UserMessageAction = {
|
||||
id: 1,
|
||||
action: "message",
|
||||
source: "user",
|
||||
message: "Hello, User!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
renderMessages({ messages: [userMessage, assistantMessage] });
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
const mockConversation: Conversation = {
|
||||
conversation_id: "123",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
created_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
selected_branch: null,
|
||||
selected_repository: null,
|
||||
git_provider: "github",
|
||||
session_api_key: null,
|
||||
url: null,
|
||||
};
|
||||
|
||||
getConversationSpy.mockResolvedValue(mockConversation);
|
||||
|
||||
renderMessages({
|
||||
messages: [userMessage, assistantMessage],
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
"HOME$LETS_START_BUILDING": "Let's start building",
|
||||
"HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
|
||||
"HOME$LOADING": "Loading...",
|
||||
"HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
|
||||
"HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
|
||||
"HOME$READ_THIS": "Read this"
|
||||
HOME$LETS_START_BUILDING: "Let's start building",
|
||||
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
|
||||
HOME$LOADING: "Loading...",
|
||||
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
|
||||
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
|
||||
HOME$READ_THIS: "Read this",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -176,9 +176,8 @@ describe("RepoConnector", () => {
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
|
||||
@@ -88,9 +88,14 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
title: "Task 1",
|
||||
},
|
||||
undefined,
|
||||
MOCK_TASK_1,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider } from "react-redux";
|
||||
@@ -7,6 +7,21 @@ import { setupStore } from "test-utils";
|
||||
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...(actual as object),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderTaskSuggestions = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -93,4 +108,26 @@ describe("TaskSuggestions", () => {
|
||||
|
||||
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the tooltip button", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
expect(tooltipButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have the correct aria-label", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
expect(tooltipButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
"TASKS$TASK_SUGGESTIONS_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the info icon", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
const icon = tooltipButton.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
|
||||
describe("BadgeInput", () => {
|
||||
it("should render the values", () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
|
||||
|
||||
expect(screen.getByText("test")).toBeInTheDocument();
|
||||
expect(screen.getByText("test2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the input's as a badge on space", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "test");
|
||||
await userEvent.type(input, " ");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on backspace", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "{backspace}");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on click", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const removeButton = screen.getByTestId("remove-button");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should not create empty badges", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={[]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, " ");
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MicroagentStatusIndicator", () => {
|
||||
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should show default completed message when status is completed but no PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "MICROAGENT$STATUS_COMPLETED",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
|
||||
});
|
||||
|
||||
it("should show creating status without PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.CREATING}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error status", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.ERROR}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prioritize PR URL over conversation link when both are provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
// Should not link to conversation when PR URL is available
|
||||
expect(link).not.toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/test-conversation",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with GitLab MR URLs", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
});
|
||||
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractPRUrls,
|
||||
containsPRUrl,
|
||||
getFirstPRUrl,
|
||||
} from "#/utils/parse-pr-url";
|
||||
|
||||
describe("parse-pr-url", () => {
|
||||
describe("extractPRUrls", () => {
|
||||
it("should extract GitHub PR URLs", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should extract GitLab MR URLs", () => {
|
||||
const text =
|
||||
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Bitbucket PR URLs", () => {
|
||||
const text =
|
||||
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://bitbucket.org/owner/repo/pull-requests/789",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Azure DevOps PR URLs", () => {
|
||||
const text =
|
||||
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract multiple PR URLs", () => {
|
||||
const text = `
|
||||
GitHub: https://github.com/owner/repo/pull/123
|
||||
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls).toContain("https://github.com/owner/repo/pull/123");
|
||||
expect(urls).toContain(
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle self-hosted GitLab URLs", () => {
|
||||
const text =
|
||||
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no PR URLs found", () => {
|
||||
const text = "This is just regular text with no PR URLs";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle URLs with HTTP instead of HTTPS", () => {
|
||||
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should remove duplicate URLs", () => {
|
||||
const text = `
|
||||
Same PR mentioned twice:
|
||||
https://github.com/owner/repo/pull/123
|
||||
https://github.com/owner/repo/pull/123
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("containsPRUrl", () => {
|
||||
it("should return true when PR URL is present", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
expect(containsPRUrl(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no PR URL is present", () => {
|
||||
const text = "This is just regular text";
|
||||
expect(containsPRUrl(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstPRUrl", () => {
|
||||
it("should return the first PR URL found", () => {
|
||||
const text = `
|
||||
First: https://github.com/owner/repo/pull/123
|
||||
Second: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/123");
|
||||
});
|
||||
|
||||
it("should return null when no PR URL is found", () => {
|
||||
const text = "This is just regular text";
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle typical microagent finish messages", () => {
|
||||
const text = `
|
||||
I have successfully created a pull request with the requested changes.
|
||||
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
|
||||
|
||||
The changes include:
|
||||
- Updated the component
|
||||
- Added tests
|
||||
- Fixed the issue
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
|
||||
});
|
||||
|
||||
it("should handle messages with PR URLs in the middle", () => {
|
||||
const text = `
|
||||
Task completed successfully! I've created a pull request at
|
||||
https://github.com/owner/repo/pull/567 with all the requested changes.
|
||||
Please review when you have a chance.
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/567");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe("HomeScreen", () => {
|
||||
const mainContainer = screen
|
||||
.getByTestId("home-screen")
|
||||
.querySelector("main");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Check for hardcoded English strings in Home components", () => {
|
||||
test("HomeHeader should not have hardcoded English strings", () => {
|
||||
const { container } = render(<HomeHeader />);
|
||||
|
||||
// Get all text content
|
||||
const text = container.textContent;
|
||||
|
||||
// List of English strings that should be translated
|
||||
const hardcodedStrings = [
|
||||
"Launch from Scratch",
|
||||
"Read this",
|
||||
];
|
||||
|
||||
// Check each string
|
||||
hardcodedStrings.forEach((str) => {
|
||||
expect(text).not.toContain(str);
|
||||
});
|
||||
});
|
||||
});
|
||||
1831
frontend/package-lock.json
generated
1831
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,39 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0-beta.9",
|
||||
"@heroui/react": "^2.8.0-beta.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.2",
|
||||
"@react-router/serve": "^7.6.2",
|
||||
"@react-router/node": "^7.6.3",
|
||||
"@react-router/serve": "^7.6.3",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-query": "^5.80.10",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.81.4",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"framer-motion": "^12.19.2",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.519.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -42,14 +42,14 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.2",
|
||||
"react-router": "^7.6.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^7.0.0",
|
||||
"web-vitals": "^5.0.3",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -80,19 +80,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/parser": "^7.27.7",
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@react-router/dev": "^7.6.2",
|
||||
"@react-router/dev": "^7.6.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/node": "^24.0.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -117,7 +117,7 @@
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.2.1",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -114,6 +114,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
".openhands/microagents/", // Path to microagents directory
|
||||
"STATUS$READY",
|
||||
"STATUS$STOPPED",
|
||||
"STATUS$ERROR",
|
||||
|
||||
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -258,19 +258,17 @@ class OpenHands {
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
suggested_task?: SuggestedTask,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
replay_json: replayJson,
|
||||
suggested_task,
|
||||
conversation_instructions: conversationInstructions,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RuntimeStatus } from "#/types/runtime-status";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -77,7 +78,7 @@ export interface Conversation {
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: string | null;
|
||||
git_provider: Provider | null;
|
||||
last_updated_at: string;
|
||||
created_at: string;
|
||||
status: ConversationStatus;
|
||||
|
||||
@@ -12,12 +12,17 @@ import { paragraph } from "../markdown/paragraph";
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
message: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
type,
|
||||
message,
|
||||
children,
|
||||
actions,
|
||||
}: React.PropsWithChildren<ChatMessageProps>) {
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [isCopy, setIsCopy] = React.useState(false);
|
||||
@@ -47,31 +52,54 @@ export function ChatMessage({
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={cn(
|
||||
"rounded-xl relative",
|
||||
"rounded-xl relative w-fit",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<div className="text-sm break-words">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2.5 -right-2.5",
|
||||
!isHovering ? "hidden" : "flex",
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words flex">
|
||||
<div>
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
|
||||
import { LikertScale } from "../feedback/likert-scale";
|
||||
@@ -35,6 +37,13 @@ interface EventMessageProps {
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -43,6 +52,10 @@ export function EventMessage({
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
@@ -82,27 +95,66 @@ export function EventMessage({
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<ErrorMessage
|
||||
errorId={event.extras.error_id}
|
||||
defaultMessage={event.message}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasObservationPair && isOpenHandsAction(event)) {
|
||||
if (hasThoughtProperty(event.args)) {
|
||||
return <ChatMessage type="agent" message={event.args.thought} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={event.args.thought}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return microagentStatus && actions ? (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isFinishAction(event)) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
);
|
||||
@@ -112,8 +164,8 @@ export function EventMessage({
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
<div className="flex flex-col self-end">
|
||||
<ChatMessage type={event.source} message={message} actions={actions}>
|
||||
{event.args.image_urls && event.args.image_urls.length > 0 && (
|
||||
<ImageCarousel size="small" images={event.args.image_urls} />
|
||||
)}
|
||||
@@ -122,15 +174,26 @@ export function EventMessage({
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{isAssistantMessage(event) &&
|
||||
event.action === "message" &&
|
||||
renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRejectObservation(event)) {
|
||||
return <ChatMessage type="agent" message={event.content} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={event.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMcpObservation(event)) {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FaBrain } from "react-icons/fa6";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
|
||||
import {
|
||||
isOpenHandsAction,
|
||||
isOpenHandsObservation,
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isFinishAction,
|
||||
} from "#/types/core/guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
|
||||
import {
|
||||
MicroagentStatus,
|
||||
EventMicroagentStatus,
|
||||
} from "#/types/microagent-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
@@ -13,10 +31,23 @@ interface MessagesProps {
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
|
||||
React.useState(false);
|
||||
const [microagentStatuses, setMicroagentStatuses] = React.useState<
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -30,6 +61,139 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
[messages],
|
||||
);
|
||||
|
||||
const getMicroagentStatusForEvent = React.useCallback(
|
||||
(eventId: number): MicroagentStatus | null => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.status || null;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentConversationIdForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.conversationId || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentPRUrlForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.prUrl || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const handleMicroagentEvent = React.useCallback(
|
||||
(socketEvent: unknown, microagentConversationId: string) => {
|
||||
// Handle error events
|
||||
const isErrorEvent = (
|
||||
evt: unknown,
|
||||
): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.ERROR }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent)
|
||||
) {
|
||||
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isFinishAction(socketEvent)
|
||||
) {
|
||||
// Check if the finish action contains a PR URL
|
||||
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
|
||||
if (prUrl) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? {
|
||||
...statusEntry,
|
||||
status: MicroagentStatus.COMPLETED,
|
||||
prUrl,
|
||||
}
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setMicroagentStatuses],
|
||||
);
|
||||
|
||||
const handleLaunchMicroagent = (
|
||||
query: string,
|
||||
target: string,
|
||||
triggers: string[],
|
||||
) => {
|
||||
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
|
||||
if (
|
||||
!conversation ||
|
||||
!conversation.selected_repository ||
|
||||
!conversation.selected_branch ||
|
||||
!conversation.git_provider ||
|
||||
!selectedEventId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
createConversationAndSubscribe({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: conversation.selected_repository,
|
||||
branch: conversation.selected_branch,
|
||||
gitProvider: conversation.git_provider,
|
||||
},
|
||||
onSuccessCallback: (newConversationId: string) => {
|
||||
setShowLaunchMicroagentModal(false);
|
||||
// Update status with conversation ID
|
||||
setMicroagentStatuses((prev) => [
|
||||
...prev.filter((status) => status.eventId !== selectedEventId),
|
||||
{
|
||||
eventId: selectedEventId,
|
||||
conversationId: newConversationId,
|
||||
status: MicroagentStatus.CREATING,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
|
||||
handleMicroagentEvent(socketEvent, newConversationId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => (
|
||||
@@ -39,6 +203,20 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
microagentStatus={getMicroagentStatusForEvent(message.id)}
|
||||
microagentConversationId={getMicroagentConversationIdForEvent(
|
||||
message.id,
|
||||
)}
|
||||
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
|
||||
actions={[
|
||||
{
|
||||
icon: <FaBrain className="w-[14px] h-[14px]" />,
|
||||
onClick: () => {
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
/>
|
||||
))}
|
||||
@@ -46,6 +224,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
{conversation?.selected_repository &&
|
||||
showLaunchMicroagentModal &&
|
||||
selectedEventId &&
|
||||
createPortal(
|
||||
<LaunchMicroagentModal
|
||||
onClose={() => setShowLaunchMicroagentModal(false)}
|
||||
onLaunch={handleLaunchMicroagent}
|
||||
selectedRepo={
|
||||
conversation.selected_repository.split("/").pop() || ""
|
||||
}
|
||||
eventId={selectedEventId}
|
||||
isLoading={isPending}
|
||||
/>,
|
||||
document.getElementById("modal-portal-exit") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../../settings/brand-button";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
|
||||
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
|
||||
import { LoadingMicroagentBody } from "./loading-microagent-body";
|
||||
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
|
||||
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
|
||||
|
||||
interface LaunchMicroagentModalProps {
|
||||
onClose: () => void;
|
||||
onLaunch: (query: string, target: string, triggers: string[]) => void;
|
||||
eventId: number;
|
||||
isLoading: boolean;
|
||||
selectedRepo: string;
|
||||
}
|
||||
|
||||
export function LaunchMicroagentModal({
|
||||
onClose,
|
||||
onLaunch,
|
||||
eventId,
|
||||
isLoading,
|
||||
selectedRepo,
|
||||
}: LaunchMicroagentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { runtimeActive } = useHandleRuntimeActive();
|
||||
const { data: prompt, isLoading: promptIsLoading } =
|
||||
useMicroagentPrompt(eventId);
|
||||
|
||||
const { data: microagents, isLoading: microagentsIsLoading } =
|
||||
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
|
||||
|
||||
const [triggers, setTriggers] = React.useState<string[]>([]);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const query = formData.get("query-input")?.toString();
|
||||
const target = formData.get("target-input")?.toString();
|
||||
|
||||
if (query && target) {
|
||||
onLaunch(query, target, triggers);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
{!runtimeActive && <LoadingMicroagentBody />}
|
||||
{runtimeActive && (
|
||||
<ModalBody className="items-start w-[728px]">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
{t("MICROAGENT$WHAT_TO_REMEMBER")}
|
||||
{promptIsLoading && <LoadingMicroagentTextarea />}
|
||||
{!promptIsLoading && (
|
||||
<textarea
|
||||
required
|
||||
data-testid="query-input"
|
||||
name="query-input"
|
||||
defaultValue={prompt}
|
||||
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="target-input"
|
||||
name="target-input"
|
||||
label={t("MICROAGENT$WHERE_TO_PUT")}
|
||||
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
|
||||
required
|
||||
allowsCustomValue
|
||||
isLoading={microagentsIsLoading}
|
||||
items={
|
||||
microagents?.map((item) => ({
|
||||
key: item,
|
||||
label: item,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="trigger-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TRIGGERS")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
<BadgeInput
|
||||
name="trigger-input"
|
||||
value={triggers}
|
||||
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
|
||||
onChange={setTriggers}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<BrandButton type="button" variant="secondary" onClick={onClose}>
|
||||
{t("MICROAGENT$CANCEL")}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={
|
||||
isLoading || promptIsLoading || microagentsIsLoading
|
||||
}
|
||||
>
|
||||
{t("MICROAGENT$LAUNCH")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
export function LoadingMicroagentBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody>
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
</h2>
|
||||
<Spinner size="lg" />
|
||||
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function LoadingMicroagentTextarea() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
required
|
||||
disabled
|
||||
defaultValue=""
|
||||
placeholder={t("MICROAGENT$LOADING_PROMPT")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface MicroagentStatusIndicatorProps {
|
||||
status: MicroagentStatus;
|
||||
conversationId?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export function MicroagentStatusIndicator({
|
||||
status,
|
||||
conversationId,
|
||||
prUrl,
|
||||
}: MicroagentStatusIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
// If there's a PR URL, show "View your PR" instead of the default completed message
|
||||
return prUrl
|
||||
? t("MICROAGENT$VIEW_YOUR_PR")
|
||||
: t("MICROAGENT$STATUS_COMPLETED");
|
||||
case MicroagentStatus.ERROR:
|
||||
return t("MICROAGENT$STATUS_ERROR");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
return <SuccessIndicator status="success" />;
|
||||
case MicroagentStatus.ERROR:
|
||||
return <SuccessIndicator status="error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = getStatusText();
|
||||
const shouldShowAsLink = !!conversationId;
|
||||
const shouldShowPRLink = !!prUrl;
|
||||
|
||||
const renderStatusText = () => {
|
||||
if (shouldShowPRLink) {
|
||||
return (
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowAsLink) {
|
||||
return (
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="underline">{statusText}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
|
||||
{getStatusIcon()}
|
||||
{renderStatusText()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface ConversationCreatedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationCreatedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$ADDING_CONTEXT")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationFinishedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationFinishedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="success" />
|
||||
<div>
|
||||
{t("MICROAGENT$SUCCESS_PR_READY")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationErroredToastProps {
|
||||
errorMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const renderConversationCreatedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationCreatedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationFinishedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationFinishedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationErroredToast = (
|
||||
conversationId: string,
|
||||
errorMessage: string,
|
||||
) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationErroredToast
|
||||
errorMessage={errorMessage}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
@@ -389,10 +389,7 @@ export function ConversationCard({
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal
|
||||
onClose={() => setMicroagentsModalVisible(false)}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,12 @@ interface EllipsisButtonProps {
|
||||
|
||||
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
|
||||
return (
|
||||
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
|
||||
<button
|
||||
data-testid="ellipsis-button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FaEllipsisV fill="#a3a3a3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -9,13 +9,9 @@ import { useConversationMicroagents } from "#/hooks/query/use-conversation-micro
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
conversationId: string | undefined;
|
||||
}
|
||||
|
||||
export function MicroagentsModal({
|
||||
onClose,
|
||||
conversationId,
|
||||
}: MicroagentsModalProps) {
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
@@ -25,10 +21,7 @@ export function MicroagentsModal({
|
||||
data: microagents,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useConversationMicroagents({
|
||||
conversationId,
|
||||
enabled: true,
|
||||
});
|
||||
} = useConversationMicroagents();
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
|
||||
@@ -118,7 +118,7 @@ export function SystemMessageModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-[60vh] overflow-auto rounded-md">
|
||||
<div className="max-h-[51vh] overflow-auto rounded-md">
|
||||
{activeTab === "system" && (
|
||||
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
|
||||
{systemMessage.content}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
|
||||
export function HomeHeader() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -28,7 +30,15 @@ export function HomeHeader() {
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
{},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && t("HOME$LAUNCH_FROM_SCRATCH")}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseRepositoryBranches = vi.fn();
|
||||
const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseRepositoryBranches.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversation.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock the modules
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-repository-branches", () => ({
|
||||
useRepositoryBranches: () => mockUseRepositoryBranches(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => mockUseCreateConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
const renderRepositorySelectionForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows loading indicator when repositories are being fetched", () => {
|
||||
// Setup loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows dropdown when repositories are loaded", () => {
|
||||
// Setup loaded repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if dropdown is displayed
|
||||
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error message when repository fetch fails", () => {
|
||||
// Setup error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Failed to fetch repositories"),
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if error message is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@@ -25,6 +26,7 @@ interface RepositorySelectionFormProps {
|
||||
export function RepositorySelectionForm({
|
||||
onRepoSelection,
|
||||
}: RepositorySelectionFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
React.useState<GitRepository | null>(null);
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
@@ -209,10 +211,19 @@ export function RepositorySelectionForm({
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation({
|
||||
selectedRepository,
|
||||
selected_branch: selectedBranch?.name,
|
||||
})
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { SuggestedTask } from "./task.types";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
@@ -23,28 +21,19 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { data: repositories } = useUserRepositories();
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const selectedRepo = repositories?.find(
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
|
||||
|
||||
return createConversation({
|
||||
selectedRepository: repo,
|
||||
suggested_task: task,
|
||||
repository: {
|
||||
name: task.repo,
|
||||
gitProvider: task.git_provider,
|
||||
},
|
||||
suggestedTask: task,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaInfoCircle } from "react-icons/fa";
|
||||
import { TaskGroup } from "./task-group";
|
||||
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
|
||||
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface TaskSuggestionsProps {
|
||||
filterFor?: string | null;
|
||||
@@ -23,7 +25,19 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
|
||||
data-testid="task-suggestions"
|
||||
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
|
||||
>
|
||||
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
|
||||
<TooltipButton
|
||||
testId="task-suggestions-info"
|
||||
tooltip={t(I18nKey.TASKS$TASK_SUGGESTIONS_TOOLTIP)}
|
||||
ariaLabel={t(I18nKey.TASKS$TASK_SUGGESTIONS_INFO)}
|
||||
className="text-[#9099AC] hover:text-white"
|
||||
placement="bottom"
|
||||
tooltipClassName="max-w-[348px]"
|
||||
>
|
||||
<FaInfoCircle size={16} />
|
||||
</TooltipButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{isLoading && <TaskSuggestionsSkeleton />}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function BrandButton({
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
|
||||
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80 cursor-pointer",
|
||||
variant === "primary" && "bg-primary text-[#0D0F11]",
|
||||
variant === "secondary" && "border border-primary text-primary",
|
||||
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
@@ -12,9 +13,12 @@ interface SettingsDropdownInputProps {
|
||||
placeholder?: string;
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
selectedKey?: string;
|
||||
isClearable?: boolean;
|
||||
allowsCustomValue?: boolean;
|
||||
required?: boolean;
|
||||
onSelectionChange?: (key: React.Key | null) => void;
|
||||
onInputChange?: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
@@ -29,13 +33,17 @@ export function SettingsDropdownInput({
|
||||
placeholder,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
defaultSelectedKey,
|
||||
selectedKey,
|
||||
isClearable,
|
||||
allowsCustomValue,
|
||||
required,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
@@ -54,8 +62,11 @@ export function SettingsDropdownInput({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
|
||||
allowsCustomValue={allowsCustomValue}
|
||||
isRequired={required}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
|
||||
@@ -26,7 +26,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="user-actions" className="w-8 h-8 relative">
|
||||
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
|
||||
<UserAvatar
|
||||
avatarUrl={user?.avatar_url}
|
||||
onClick={toggleAccountMenu}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center",
|
||||
"w-8 h-8 rounded-full flex items-center justify-center cursor-pointer",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
type="button"
|
||||
data-testid="suggestion"
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold cursor-pointer"
|
||||
>
|
||||
{t(suggestion.label)}
|
||||
</button>
|
||||
|
||||
21
frontend/src/components/shared/badge.tsx
Normal file
21
frontend/src/components/shared/badge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BrandBadge({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<BrandBadgeProps>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm leading-4 text-[#0D0F11] font-semibold tracking-tighter bg-primary p-1 rounded-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function ActionButton({
|
||||
<button
|
||||
onClick={() => handleAction(action)}
|
||||
disabled={isDisabled}
|
||||
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out bg-gray-200 p-2 rounded-full"
|
||||
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
|
||||
type="button"
|
||||
>
|
||||
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
|
||||
|
||||
@@ -29,6 +29,7 @@ export function ConversationPanelButton({
|
||||
<FaListUl
|
||||
size={22}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
isOpen ? "text-white" : "text-[#9099AC]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CopyToClipboardButton({
|
||||
aria-label={t(
|
||||
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
|
||||
)}
|
||||
className="button-base p-1 absolute top-1 right-1"
|
||||
className="button-base p-1 cursor-pointer"
|
||||
>
|
||||
{mode === "copy" && <CopyIcon width={15} height={15} />}
|
||||
{mode === "copied" && <CheckmarkIcon width={15} height={15} />}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function StopButton({ isDisabled, onClick }: StopButtonProps) {
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
|
||||
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="w-[10px] h-[10px] bg-white" />
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
type="submit"
|
||||
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
|
||||
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<ArrowSendIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { Tooltip, TooltipProps } from "@heroui/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -12,7 +12,9 @@ export interface TooltipButtonProps {
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
disabled?: boolean;
|
||||
placement?: TooltipProps["placement"];
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -24,7 +26,9 @@ export function TooltipButton({
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
tooltipClassName,
|
||||
disabled = false,
|
||||
placement = "right",
|
||||
}: TooltipButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick && !disabled) {
|
||||
@@ -118,7 +122,12 @@ export function TooltipButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} closeDelay={100} placement="right">
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
closeDelay={100}
|
||||
placement={placement}
|
||||
className={tooltipClassName}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function TrajectoryActionButton({
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className="button-base p-1 hover:bg-neutral-500"
|
||||
className="button-base p-1 hover:bg-neutral-500 cursor-pointer"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { FaX } from "react-icons/fa6";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BrandBadge } from "../badge";
|
||||
|
||||
interface BadgeInputProps {
|
||||
name?: string;
|
||||
value: string[];
|
||||
placeholder?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
export function BadgeInput({
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: BadgeInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// If pressing Backspace with empty input, remove the last badge
|
||||
if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
|
||||
const newBadges = [...value];
|
||||
newBadges.pop();
|
||||
onChange(newBadges);
|
||||
return;
|
||||
}
|
||||
|
||||
// If pressing Space or Enter with non-empty input, add a new badge
|
||||
if (e.key === " " && inputValue.trim() !== "") {
|
||||
e.preventDefault();
|
||||
const newBadge = inputValue.trim();
|
||||
onChange([...value, newBadge]);
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBadge = (indexToRemove: number) => {
|
||||
onChange(value.filter((_, index) => index !== indexToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"flex flex-wrap items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{value.map((badge, index) => (
|
||||
<div key={index}>
|
||||
<BrandBadge className="flex items-center gap-0.5">
|
||||
{badge}
|
||||
<button
|
||||
data-testid="remove-button"
|
||||
type="button"
|
||||
onClick={() => removeBadge(index)}
|
||||
>
|
||||
<FaX className="w-3 h-3 text-black" />
|
||||
</button>
|
||||
</BrandBadge>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
data-testid={name || "badge-input"}
|
||||
name={name}
|
||||
value={inputValue}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-grow outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function ModelSelector({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row w-[full] md:w-[680px] justify-between gap-4 md:gap-[46px]">
|
||||
<div className="flex flex-col md:flex-row w-[full] max-w-[680px] justify-between gap-4 md:gap-[46px]">
|
||||
<fieldset className="flex flex-col gap-2.5 w-full">
|
||||
<label className="text-sm">{t(I18nKey.LLM$PROVIDER)}</label>
|
||||
<Autocomplete
|
||||
|
||||
345
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
345
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import {
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isStatusUpdate,
|
||||
} from "#/types/core/guards";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import {
|
||||
renderConversationErroredToast,
|
||||
renderConversationCreatedToast,
|
||||
renderConversationFinishedToast,
|
||||
} from "#/components/features/chat/microagent/microagent-status-toast";
|
||||
|
||||
interface ConversationSocket {
|
||||
socket: Socket;
|
||||
isConnected: boolean;
|
||||
events: OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
interface ConversationSubscriptionsContextType {
|
||||
activeConversationIds: string[];
|
||||
subscribeToConversation: (options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => void;
|
||||
unsubscribeFromConversation: (conversationId: string) => void;
|
||||
isSubscribedToConversation: (conversationId: string) => boolean;
|
||||
getEventsForConversation: (conversationId: string) => OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
const ConversationSubscriptionsContext =
|
||||
createContext<ConversationSubscriptionsContextType>({
|
||||
activeConversationIds: [],
|
||||
subscribeToConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
unsubscribeFromConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
isSubscribedToConversation: () => false,
|
||||
getEventsForConversation: () => [],
|
||||
});
|
||||
|
||||
const isErrorEvent = (
|
||||
event: unknown,
|
||||
): event is { error: true; message: string } =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"error" in event &&
|
||||
event.error === true &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string";
|
||||
|
||||
const isAgentStatusError = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event) &&
|
||||
event.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
export function ConversationSubscriptionsProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [activeConversationIds, setActiveConversationIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [conversationSockets, setConversationSockets] = useState<
|
||||
Record<string, ConversationSocket>
|
||||
>({});
|
||||
const eventHandlersRef = useRef<Record<string, (event: unknown) => void>>({});
|
||||
|
||||
// Cleanup function to remove all subscriptions when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Store the current sockets in a local variable to avoid closure issues
|
||||
const socketsToDisconnect = { ...conversationSockets };
|
||||
|
||||
if (Object.keys(socketsToDisconnect).length > 0) {
|
||||
console.warn(
|
||||
`Cleaning up ${Object.keys(socketsToDisconnect).length} socket connections`,
|
||||
);
|
||||
}
|
||||
|
||||
Object.values(socketsToDisconnect).forEach((socketData) => {
|
||||
if (socketData.socket) {
|
||||
socketData.socket.removeAllListeners();
|
||||
socketData.socket.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unsubscribeFromConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
console.warn(`Unsubscribing from conversation ${conversationId}`);
|
||||
|
||||
// Get a local reference to the socket data to avoid race conditions
|
||||
const socketData = conversationSockets[conversationId];
|
||||
|
||||
if (socketData) {
|
||||
const { socket } = socketData;
|
||||
const handler = eventHandlersRef.current[conversationId];
|
||||
|
||||
if (socket) {
|
||||
// First remove specific event handlers
|
||||
if (handler) {
|
||||
socket.off("oh_event", handler);
|
||||
}
|
||||
|
||||
// Then remove all listeners to be safe
|
||||
socket.removeAllListeners();
|
||||
|
||||
// Finally disconnect the socket
|
||||
socket.disconnect();
|
||||
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} disconnected`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update state to remove the socket
|
||||
setConversationSockets((prev) => {
|
||||
const newSockets = { ...prev };
|
||||
delete newSockets[conversationId];
|
||||
return newSockets;
|
||||
});
|
||||
|
||||
// Remove from active IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.filter((id) => id !== conversationId),
|
||||
);
|
||||
|
||||
// Clean up event handler reference
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const subscribeToConversation = useCallback(
|
||||
(options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
|
||||
options;
|
||||
|
||||
// If already subscribed, don't create a new subscription
|
||||
if (conversationSockets[conversationId]) {
|
||||
console.warn(`Already subscribed to conversation ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Subscribing to conversation ${conversationId}`);
|
||||
|
||||
// Create event handler for this subscription
|
||||
const handleOhEvent = (event: unknown) => {
|
||||
// Call the custom event handler if provided
|
||||
if (onEvent) {
|
||||
onEvent(event, conversationId);
|
||||
}
|
||||
|
||||
// Update the events for this subscription
|
||||
if (isOpenHandsEvent(event)) {
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
events: [...(prev[conversationId]?.events || []), event],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (isErrorEvent(event) || isAgentStatusError(event)) {
|
||||
renderConversationErroredToast(
|
||||
conversationId,
|
||||
isErrorEvent(event)
|
||||
? event.message
|
||||
: "Unknown error, please try again",
|
||||
);
|
||||
} else if (isStatusUpdate(event)) {
|
||||
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
|
||||
renderConversationCreatedToast(conversationId);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event)
|
||||
) {
|
||||
if (event.extras.agent_state === AgentState.FINISHED) {
|
||||
renderConversationFinishedToast(conversationId);
|
||||
unsubscribeFromConversation(conversationId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store the event handler in ref for cleanup
|
||||
eventHandlersRef.current[conversationId] = handleOhEvent;
|
||||
|
||||
try {
|
||||
// Create socket connection
|
||||
const socket = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
session_api_key: sessionApiKey,
|
||||
providers_set: providersSet,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
socket.on("connect", () => {
|
||||
console.warn(`Socket for conversation ${conversationId} CONNECTED!`);
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
||||
reason,
|
||||
);
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("oh_event", handleOhEvent);
|
||||
|
||||
// Add the socket to our state first
|
||||
setConversationSockets((prev) => ({
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
socket,
|
||||
isConnected: socket.connected,
|
||||
events: [],
|
||||
},
|
||||
}));
|
||||
|
||||
// Then add to active conversation IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.includes(conversationId) ? prev : [...prev, conversationId],
|
||||
);
|
||||
|
||||
console.warn(
|
||||
`Successfully subscribed to conversation ${conversationId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error subscribing to conversation ${conversationId}:`,
|
||||
error,
|
||||
);
|
||||
// Clean up the event handler if there was an error
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const isSubscribedToConversation = useCallback(
|
||||
(conversationId: string) => !!conversationSockets[conversationId],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const getEventsForConversation = useCallback(
|
||||
(conversationId: string) =>
|
||||
conversationSockets[conversationId]?.events || [],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
}),
|
||||
[
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationSubscriptionsContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationSubscriptionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversationSubscriptions() {
|
||||
return useContext(ConversationSubscriptionsContext);
|
||||
}
|
||||
@@ -328,6 +328,7 @@ export function WsClientProvider({
|
||||
transports: ["websocket"],
|
||||
query,
|
||||
});
|
||||
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", handleError);
|
||||
|
||||
@@ -67,6 +67,7 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -1,58 +1,47 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
repository?: {
|
||||
name: string;
|
||||
gitProvider: Provider;
|
||||
branch?: string;
|
||||
};
|
||||
suggestedTask?: SuggestedTask;
|
||||
conversationInstructions?: string;
|
||||
}
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, replayJson } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
q?: string;
|
||||
selectedRepository?: GitRepository | null;
|
||||
selected_branch?: string;
|
||||
suggested_task?: SuggestedTask;
|
||||
}) => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
mutationFn: async (variables: CreateConversationVariables) => {
|
||||
const { query, repository, suggestedTask, conversationInstructions } =
|
||||
variables;
|
||||
|
||||
return OpenHands.createConversation(
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.full_name
|
||||
: undefined,
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.git_provider
|
||||
: undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
variables.suggested_task || undefined,
|
||||
variables.selected_branch,
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
suggestedTask,
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
has_replay_json: !!replayJson,
|
||||
query_character_length: query?.length,
|
||||
has_repository: !!repository,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
interface UseConversationMicroagentsOptions {
|
||||
conversationId: string | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
export const useConversationMicroagents = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
export const useConversationMicroagents = ({
|
||||
conversationId,
|
||||
enabled = true,
|
||||
}: UseConversationMicroagentsOptions) =>
|
||||
useQuery({
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "microagents"],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) {
|
||||
@@ -19,7 +14,8 @@ export const useConversationMicroagents = ({
|
||||
const data = await OpenHands.getMicroagents(conversationId);
|
||||
return data.microagents;
|
||||
},
|
||||
enabled: !!conversationId && enabled,
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGetMicroagentPrompt = ({ eventId }: { eventId: number }) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", "remember_prompt", conversationId, eventId],
|
||||
queryFn: () => OpenHands.getMicroagentPrompt(conversationId, eventId),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useGetMicroagents = (microagentDirectory: string) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", "microagents", conversationId, microagentDirectory],
|
||||
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
|
||||
enabled: !!conversationId,
|
||||
select: (data) =>
|
||||
data.map((fileName) => fileName.replace(microagentDirectory, "")),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
export const useMicroagentPrompt = (eventId: number) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["memory", "prompt", conversationId, eventId],
|
||||
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { useCreateConversation } from "./mutation/use-create-conversation";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
/**
|
||||
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
|
||||
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
|
||||
* multiple conversations simultaneously.
|
||||
*/
|
||||
export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const {
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
} = useConversationSubscriptions();
|
||||
|
||||
const createConversationAndSubscribe = React.useCallback(
|
||||
({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
onSuccessCallback,
|
||||
onEventCallback,
|
||||
}: {
|
||||
query: string;
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
onSuccessCallback?: (conversationId: string) => void;
|
||||
onEventCallback?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
createConversation(
|
||||
{
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
let baseUrl = "";
|
||||
if (data?.url && !data.url.startsWith("/")) {
|
||||
baseUrl = new URL(data.url).host;
|
||||
} else {
|
||||
baseUrl =
|
||||
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
|
||||
window?.location.host;
|
||||
}
|
||||
|
||||
// Subscribe to the conversation
|
||||
subscribeToConversation({
|
||||
conversationId: data.conversation_id,
|
||||
sessionApiKey: data.session_api_key,
|
||||
providersSet: providers,
|
||||
baseUrl,
|
||||
onEvent: onEventCallback,
|
||||
});
|
||||
|
||||
// Call the success callback if provided
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback(data.conversation_id);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[createConversation, subscribeToConversation, providers],
|
||||
);
|
||||
|
||||
return {
|
||||
createConversationAndSubscribe,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,26 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
|
||||
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
|
||||
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
|
||||
MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT",
|
||||
MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER",
|
||||
MICROAGENT$WHAT_TO_REMEMBER = "MICROAGENT$WHAT_TO_REMEMBER",
|
||||
MICROAGENT$ADD_TRIGGERS = "MICROAGENT$ADD_TRIGGERS",
|
||||
MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME",
|
||||
MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT",
|
||||
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
|
||||
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
|
||||
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
|
||||
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
|
||||
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
|
||||
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
|
||||
MICROAGENT$DESCRIBE_WHAT_TO_ADD = "MICROAGENT$DESCRIBE_WHAT_TO_ADD",
|
||||
MICROAGENT$SELECT_FILE_OR_CUSTOM = "MICROAGENT$SELECT_FILE_OR_CUSTOM",
|
||||
MICROAGENT$TYPE_TRIGGER_SPACE = "MICROAGENT$TYPE_TRIGGER_SPACE",
|
||||
MICROAGENT$LOADING_PROMPT = "MICROAGENT$LOADING_PROMPT",
|
||||
MICROAGENT$CANCEL = "MICROAGENT$CANCEL",
|
||||
MICROAGENT$LAUNCH = "MICROAGENT$LAUNCH",
|
||||
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
|
||||
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
|
||||
HOME$READ_THIS = "HOME$READ_THIS",
|
||||
@@ -611,6 +632,8 @@ export enum I18nKey {
|
||||
REPOSITORY$SELECT_REPO = "REPOSITORY$SELECT_REPO",
|
||||
TASKS$SUGGESTED_TASKS = "TASKS$SUGGESTED_TASKS",
|
||||
TASKS$NO_TASKS_AVAILABLE = "TASKS$NO_TASKS_AVAILABLE",
|
||||
TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO",
|
||||
TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP",
|
||||
PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD",
|
||||
GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK",
|
||||
GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK",
|
||||
|
||||
@@ -1,4 +1,341 @@
|
||||
{
|
||||
"MICROAGENT$NO_REPOSITORY_FOUND": {
|
||||
"en": "No repository found to launch microagent",
|
||||
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
|
||||
"zh-CN": "未找到启动微代理的存储库",
|
||||
"zh-TW": "未找到啟動微代理的存儲庫",
|
||||
"ko-KR": "마이크로에이전트를 시작할 저장소를 찾을 수 없습니다",
|
||||
"no": "Ingen repository funnet for å starte mikroagent",
|
||||
"it": "Nessun repository trovato per avviare il microagente",
|
||||
"pt": "Nenhum repositório encontrado para iniciar o microagente",
|
||||
"es": "No se encontró ningún repositorio para iniciar el microagente",
|
||||
"ar": "لم يتم العثور على مستودع لإطلاق الوكيل المصغر",
|
||||
"fr": "Aucun dépôt trouvé pour lancer le micro-agent",
|
||||
"tr": "Mikro ajanı başlatmak için depo bulunamadı",
|
||||
"de": "Kein Repository gefunden, um Microagent zu starten",
|
||||
"uk": "Не знайдено репозиторій для запуску мікроагента"
|
||||
},
|
||||
|
||||
"MICROAGENT$ADD_TO_MICROAGENT": {
|
||||
"en": "Add to Microagent",
|
||||
"ja": "マイクロエージェントに追加",
|
||||
"zh-CN": "添加到微代理",
|
||||
"zh-TW": "添加到微代理",
|
||||
"ko-KR": "마이크로에이전트에 추가",
|
||||
"no": "Legg til i mikroagent",
|
||||
"it": "Aggiungi al microagente",
|
||||
"pt": "Adicionar ao microagente",
|
||||
"es": "Añadir al microagente",
|
||||
"ar": "إضافة إلى الوكيل المصغر",
|
||||
"fr": "Ajouter au micro-agent",
|
||||
"tr": "Mikro ajana ekle",
|
||||
"de": "Zum Microagent hinzufügen",
|
||||
"uk": "Додати до мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_ADD": {
|
||||
"en": "What would you like to add to the Microagent?",
|
||||
"ja": "マイクロエージェントに何を追加しますか?",
|
||||
"zh-CN": "您想添加什么到微代理?",
|
||||
"zh-TW": "您想添加什麼到微代理?",
|
||||
"ko-KR": "마이크로에이전트에 무엇을 추가하시겠습니까?",
|
||||
"no": "Hva vil du legge til i mikroagenten?",
|
||||
"it": "Cosa vorresti aggiungere al microagente?",
|
||||
"pt": "O que você gostaria de adicionar ao microagente?",
|
||||
"es": "¿Qué te gustaría añadir al microagente?",
|
||||
"ar": "ماذا تريد أن تضيف إلى الوكيل المصغر؟",
|
||||
"fr": "Que souhaitez-vous ajouter au micro-agent ?",
|
||||
"tr": "Mikro ajana ne eklemek istersiniz?",
|
||||
"de": "Was möchten Sie zum Microagent hinzufügen?",
|
||||
"uk": "Що ви хочете додати до мікроагента?"
|
||||
},
|
||||
"MICROAGENT$WHERE_TO_PUT": {
|
||||
"en": "Where should we put it?",
|
||||
"ja": "どこに配置しますか?",
|
||||
"zh-CN": "我们应该把它放在哪里?",
|
||||
"zh-TW": "我們應該把它放在哪裡?",
|
||||
"ko-KR": "어디에 넣을까요?",
|
||||
"no": "Hvor skal vi plassere det?",
|
||||
"it": "Dove dovremmo metterlo?",
|
||||
"pt": "Onde devemos colocá-lo?",
|
||||
"es": "¿Dónde deberíamos ponerlo?",
|
||||
"ar": "أين يجب أن نضعه؟",
|
||||
"fr": "Où devons-nous le mettre ?",
|
||||
"tr": "Nereye koyalım?",
|
||||
"de": "Wo sollen wir es platzieren?",
|
||||
"uk": "Куди ми повинні його помістити?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGER": {
|
||||
"en": "Add a trigger for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til en utløser for mikroagenten",
|
||||
"it": "Aggiungi un trigger per il microagente",
|
||||
"pt": "Adicionar um gatilho para o microagente",
|
||||
"es": "Añadir un disparador para el microagente",
|
||||
"ar": "إضافة مشغل للوكيل المصغر",
|
||||
"fr": "Ajouter un déclencheur pour le micro-agent",
|
||||
"tr": "Mikro ajan için bir tetikleyici ekleyin",
|
||||
"de": "Fügen Sie einen Auslöser für den Microagent hinzu",
|
||||
"uk": "Додати тригер для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_REMEMBER": {
|
||||
"en": "What would you like your microagent to remember?",
|
||||
"ja": "マイクロエージェントに何を覚えさせたいですか?",
|
||||
"zh-CN": "您希望您的微代理记住什么?",
|
||||
"zh-TW": "您希望您的微代理記住什麼?",
|
||||
"ko-KR": "마이크로에이전트가 무엇을 기억하기를 원하시나요?",
|
||||
"no": "Hva vil du at mikroagenten din skal huske?",
|
||||
"it": "Cosa vorresti che il tuo microagente ricordasse?",
|
||||
"pt": "O que você gostaria que seu microagente lembrasse?",
|
||||
"es": "¿Qué te gustaría que tu microagente recordara?",
|
||||
"ar": "ماذا تريد أن يتذكر وكيلك المصغر؟",
|
||||
"fr": "Que souhaitez-vous que votre micro-agent se souvienne ?",
|
||||
"tr": "Mikro ajanınızın neyi hatırlamasını istersiniz?",
|
||||
"de": "Was soll sich Ihr Microagent merken?",
|
||||
"uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGERS": {
|
||||
"en": "Add triggers for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til utløsere for mikroagenten",
|
||||
"it": "Aggiungi trigger per il microagente",
|
||||
"pt": "Adicionar gatilhos para o microagente",
|
||||
"es": "Añadir disparadores para el microagente",
|
||||
"ar": "إضافة مشغلات للوكيل المصغر",
|
||||
"fr": "Ajouter des déclencheurs pour le micro-agent",
|
||||
"tr": "Mikro ajan için tetikleyiciler ekleyin",
|
||||
"de": "Auslöser für den Microagent hinzufügen",
|
||||
"uk": "Додати тригери для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WAIT_FOR_RUNTIME": {
|
||||
"en": "Please wait for the runtime to be active.",
|
||||
"ja": "ランタイムがアクティブになるまでお待ちください。",
|
||||
"zh-CN": "请等待运行时激活。",
|
||||
"zh-TW": "請等待運行時激活。",
|
||||
"ko-KR": "런타임이 활성화될 때까지 기다려주세요.",
|
||||
"no": "Vennligst vent til kjøretidsmiljøet er aktivt.",
|
||||
"it": "Attendere che il runtime sia attivo.",
|
||||
"pt": "Aguarde até que o tempo de execução esteja ativo.",
|
||||
"es": "Por favor, espere a que el tiempo de ejecución esté activo.",
|
||||
"ar": "يرجى الانتظار حتى يصبح وقت التشغيل نشطًا.",
|
||||
"fr": "Veuillez attendre que le runtime soit actif.",
|
||||
"tr": "Lütfen çalışma zamanının aktif olmasını bekleyin.",
|
||||
"de": "Bitte warten Sie, bis die Laufzeitumgebung aktiv ist.",
|
||||
"uk": "Будь ласка, зачекайте, поки середовище виконання стане активним."
|
||||
},
|
||||
"MICROAGENT$ADDING_CONTEXT": {
|
||||
"en": "OpenHands is adding this new context to your respository. We'll let you know when the pull request is ready.",
|
||||
"ja": "OpenHandsはこの新しいコンテキストをあなたのリポジトリに追加しています。プルリクエストの準備ができたらお知らせします。",
|
||||
"zh-CN": "OpenHands正在将此新上下文添加到您的存储库中。拉取请求准备好后,我们会通知您。",
|
||||
"zh-TW": "OpenHands正在將此新上下文添加到您的存儲庫中。拉取請求準備好後,我們會通知您。",
|
||||
"ko-KR": "OpenHands가 이 새로운 컨텍스트를 저장소에 추가하고 있습니다. 풀 리퀘스트가 준비되면 알려드리겠습니다.",
|
||||
"no": "OpenHands legger til denne nye konteksten i ditt repository. Vi gir deg beskjed når pull-forespørselen er klar.",
|
||||
"it": "OpenHands sta aggiungendo questo nuovo contesto al tuo repository. Ti faremo sapere quando la pull request sarà pronta.",
|
||||
"pt": "OpenHands está adicionando este novo contexto ao seu repositório. Avisaremos quando o pull request estiver pronto.",
|
||||
"es": "OpenHands está añadiendo este nuevo contexto a tu repositorio. Te avisaremos cuando la solicitud de extracción esté lista.",
|
||||
"ar": "يقوم OpenHands بإضافة هذا السياق الجديد إلى مستودعك. سنعلمك عندما يكون طلب السحب جاهزًا.",
|
||||
"fr": "OpenHands ajoute ce nouveau contexte à votre dépôt. Nous vous informerons lorsque la pull request sera prête.",
|
||||
"tr": "OpenHands bu yeni bağlamı deponuza ekliyor. Çekme isteği hazır olduğunda size haber vereceğiz.",
|
||||
"de": "OpenHands fügt diesen neuen Kontext zu Ihrem Repository hinzu. Wir informieren Sie, wenn der Pull Request bereit ist.",
|
||||
"uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий."
|
||||
},
|
||||
"MICROAGENT$VIEW_CONVERSATION": {
|
||||
"en": "View Conversation",
|
||||
"ja": "会話を表示",
|
||||
"zh-CN": "查看对话",
|
||||
"zh-TW": "查看對話",
|
||||
"ko-KR": "대화 보기",
|
||||
"no": "Vis samtale",
|
||||
"it": "Visualizza conversazione",
|
||||
"pt": "Ver conversa",
|
||||
"es": "Ver conversación",
|
||||
"ar": "عرض المحادثة",
|
||||
"fr": "Voir la conversation",
|
||||
"tr": "Konuşmayı Görüntüle",
|
||||
"de": "Konversation anzeigen",
|
||||
"uk": "Переглянути розмову"
|
||||
},
|
||||
"MICROAGENT$SUCCESS_PR_READY": {
|
||||
"en": "Success! Your microagent pull request is ready.",
|
||||
"ja": "成功!マイクロエージェントのプルリクエストの準備ができました。",
|
||||
"zh-CN": "成功!您的微代理拉取请求已准备就绪。",
|
||||
"zh-TW": "成功!您的微代理拉取請求已準備就緒。",
|
||||
"ko-KR": "성공! 마이크로에이전트 풀 리퀘스트가 준비되었습니다.",
|
||||
"no": "Suksess! Din mikroagent pull request er klar.",
|
||||
"it": "Successo! La tua pull request del microagente è pronta.",
|
||||
"pt": "Sucesso! Seu pull request de microagente está pronto.",
|
||||
"es": "¡Éxito! Tu solicitud de extracción de microagente está lista.",
|
||||
"ar": "نجاح! طلب سحب الوكيل المصغر الخاص بك جاهز.",
|
||||
"fr": "Succès ! Votre pull request de micro-agent est prête.",
|
||||
"tr": "Başarılı! Mikro ajan çekme isteğiniz hazır.",
|
||||
"de": "Erfolg! Ihr Microagent Pull Request ist bereit.",
|
||||
"uk": "Успіх! Ваш запит на витягування мікроагента готовий."
|
||||
},
|
||||
"MICROAGENT$STATUS_CREATING": {
|
||||
"en": "Modifying microagent...",
|
||||
"ja": "マイクロエージェントを変更中...",
|
||||
"zh-CN": "正在修改微代理...",
|
||||
"zh-TW": "正在修改微代理...",
|
||||
"ko-KR": "마이크로에이전트 수정 중...",
|
||||
"no": "Endrer mikroagent...",
|
||||
"it": "Modifica del microagente in corso...",
|
||||
"pt": "Modificando microagente...",
|
||||
"es": "Modificando microagente...",
|
||||
"ar": "تعديل الوكيل المصغر...",
|
||||
"fr": "Modification du micro-agent en cours...",
|
||||
"tr": "Mikro ajan değiştiriliyor...",
|
||||
"de": "Microagent wird geändert...",
|
||||
"uk": "Зміна мікроагента..."
|
||||
},
|
||||
"MICROAGENT$STATUS_COMPLETED": {
|
||||
"en": "View microagent update",
|
||||
"ja": "マイクロエージェントの更新を表示",
|
||||
"zh-CN": "查看微代理更新",
|
||||
"zh-TW": "查看微代理更新",
|
||||
"ko-KR": "마이크로에이전트 업데이트 보기",
|
||||
"no": "Vis mikroagent oppdatering",
|
||||
"it": "Visualizza aggiornamento microagente",
|
||||
"pt": "Ver atualização do microagente",
|
||||
"es": "Ver actualización del microagente",
|
||||
"ar": "عرض تحديث الوكيل المصغر",
|
||||
"fr": "Voir la mise à jour du micro-agent",
|
||||
"tr": "Mikro ajan güncellemesini görüntüle",
|
||||
"de": "Microagent-Update anzeigen",
|
||||
"uk": "Переглянути оновлення мікроагента"
|
||||
},
|
||||
"MICROAGENT$STATUS_ERROR": {
|
||||
"en": "Microagent encountered an error",
|
||||
"ja": "マイクロエージェントでエラーが発生しました",
|
||||
"zh-CN": "微代理遇到错误",
|
||||
"zh-TW": "微代理遇到錯誤",
|
||||
"ko-KR": "마이크로에이전트에서 오류가 발생했습니다",
|
||||
"no": "Mikroagent støtte på en feil",
|
||||
"it": "Il microagente ha riscontrato un errore",
|
||||
"pt": "Microagente encontrou um erro",
|
||||
"es": "El microagente encontró un error",
|
||||
"ar": "واجه الوكيل المصغر خطأ",
|
||||
"fr": "Le micro-agent a rencontré une erreur",
|
||||
"tr": "Mikro ajan bir hatayla karşılaştı",
|
||||
"de": "Microagent ist auf einen Fehler gestoßen",
|
||||
"uk": "Мікроагент зіткнувся з помилкою"
|
||||
},
|
||||
"MICROAGENT$VIEW_YOUR_PR": {
|
||||
"en": "View your PR",
|
||||
"ja": "PRを表示",
|
||||
"zh-CN": "查看您的PR",
|
||||
"zh-TW": "查看您的PR",
|
||||
"ko-KR": "PR 보기",
|
||||
"no": "Se din PR",
|
||||
"it": "Visualizza la tua PR",
|
||||
"pt": "Ver seu PR",
|
||||
"es": "Ver tu PR",
|
||||
"ar": "عرض طلب السحب الخاص بك",
|
||||
"fr": "Voir votre PR",
|
||||
"tr": "PR'ınızı görüntüleyin",
|
||||
"de": "Ihre PR anzeigen",
|
||||
"uk": "Переглянути ваш PR"
|
||||
},
|
||||
"MICROAGENT$DESCRIBE_WHAT_TO_ADD": {
|
||||
"en": "Describe what you want to add to the Microagent...",
|
||||
"ja": "マイクロエージェントに追加したい内容を説明してください...",
|
||||
"zh-CN": "描述您想添加到微代理的内容...",
|
||||
"zh-TW": "描述您想添加到微代理的內容...",
|
||||
"ko-KR": "마이크로에이전트에 추가하고 싶은 내용을 설명하세요...",
|
||||
"no": "Beskriv hva du vil legge til i mikroagenten...",
|
||||
"it": "Descrivi cosa vuoi aggiungere al microagente...",
|
||||
"pt": "Descreva o que você deseja adicionar ao microagente...",
|
||||
"es": "Describe lo que quieres añadir al microagente...",
|
||||
"ar": "صف ما تريد إضافته إلى الوكيل المصغر...",
|
||||
"fr": "Décrivez ce que vous souhaitez ajouter au micro-agent...",
|
||||
"tr": "Mikro ajana eklemek istediğinizi açıklayın...",
|
||||
"de": "Beschreiben Sie, was Sie zum Microagent hinzufügen möchten...",
|
||||
"uk": "Опишіть, що ви хочете додати до мікроагента..."
|
||||
},
|
||||
"MICROAGENT$SELECT_FILE_OR_CUSTOM": {
|
||||
"en": "Select a microagent file or enter a custom value",
|
||||
"ja": "マイクロエージェントファイルを選択するか、カスタム値を入力してください",
|
||||
"zh-CN": "选择微代理文件或输入自定义值",
|
||||
"zh-TW": "選擇微代理文件或輸入自定義值",
|
||||
"ko-KR": "마이크로에이전트 파일을 선택하거나 사용자 지정 값을 입력하세요",
|
||||
"no": "Velg en mikroagent-fil eller skriv inn en egendefinert verdi",
|
||||
"it": "Seleziona un file microagente o inserisci un valore personalizzato",
|
||||
"pt": "Selecione um arquivo de microagente ou insira um valor personalizado",
|
||||
"es": "Selecciona un archivo de microagente o introduce un valor personalizado",
|
||||
"ar": "حدد ملف وكيل مصغر أو أدخل قيمة مخصصة",
|
||||
"fr": "Sélectionnez un fichier micro-agent ou entrez une valeur personnalisée",
|
||||
"tr": "Bir mikro ajan dosyası seçin veya özel bir değer girin",
|
||||
"de": "Wählen Sie eine Microagent-Datei aus oder geben Sie einen benutzerdefinierten Wert ein",
|
||||
"uk": "Виберіть файл мікроагента або введіть власне значення"
|
||||
},
|
||||
"MICROAGENT$TYPE_TRIGGER_SPACE": {
|
||||
"en": "Type a trigger and press Space to add it",
|
||||
"ja": "トリガーを入力し、スペースキーを押して追加してください",
|
||||
"zh-CN": "输入触发器并按空格键添加",
|
||||
"zh-TW": "輸入觸發器並按空格鍵添加",
|
||||
"ko-KR": "트리거를 입력하고 스페이스바를 눌러 추가하세요",
|
||||
"no": "Skriv inn en utløser og trykk mellomrom for å legge den til",
|
||||
"it": "Digita un trigger e premi Spazio per aggiungerlo",
|
||||
"pt": "Digite um gatilho e pressione Espaço para adicioná-lo",
|
||||
"es": "Escribe un disparador y pulsa Espacio para añadirlo",
|
||||
"ar": "اكتب مشغلًا واضغط على المسافة لإضافته",
|
||||
"fr": "Tapez un déclencheur et appuyez sur Espace pour l'ajouter",
|
||||
"tr": "Bir tetikleyici yazın ve eklemek için Boşluk tuşuna basın",
|
||||
"de": "Geben Sie einen Auslöser ein und drücken Sie die Leertaste, um ihn hinzuzufügen",
|
||||
"uk": "Введіть тригер і натисніть пробіл, щоб додати його"
|
||||
},
|
||||
"MICROAGENT$LOADING_PROMPT": {
|
||||
"en": "Loading prompt...",
|
||||
"ja": "プロンプトを読み込み中...",
|
||||
"zh-CN": "加载提示中...",
|
||||
"zh-TW": "加載提示中...",
|
||||
"ko-KR": "프롬프트 로딩 중...",
|
||||
"no": "Laster inn prompt...",
|
||||
"it": "Caricamento prompt...",
|
||||
"pt": "Carregando prompt...",
|
||||
"es": "Cargando prompt...",
|
||||
"ar": "جاري تحميل المطالبة...",
|
||||
"fr": "Chargement du prompt...",
|
||||
"tr": "İstem yükleniyor...",
|
||||
"de": "Prompt wird geladen...",
|
||||
"uk": "Завантаження підказки..."
|
||||
},
|
||||
"MICROAGENT$CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"ar": "إلغاء",
|
||||
"fr": "Annuler",
|
||||
"tr": "İptal",
|
||||
"de": "Abbrechen",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"MICROAGENT$LAUNCH": {
|
||||
"en": "Launch",
|
||||
"ja": "起動",
|
||||
"zh-CN": "启动",
|
||||
"zh-TW": "啟動",
|
||||
"ko-KR": "시작",
|
||||
"no": "Start",
|
||||
"it": "Avvia",
|
||||
"pt": "Iniciar",
|
||||
"es": "Iniciar",
|
||||
"ar": "إطلاق",
|
||||
"fr": "Lancer",
|
||||
"tr": "Başlat",
|
||||
"de": "Starten",
|
||||
"uk": "Запустити"
|
||||
},
|
||||
"STATUS$WEBSOCKET_CLOSED": {
|
||||
"en": "The WebSocket connection was closed.",
|
||||
"ja": "WebSocket接続が閉じられました。",
|
||||
@@ -9775,6 +10112,38 @@
|
||||
"de": "Keine Aufgaben verfügbar",
|
||||
"uk": "Немає доступних завдань"
|
||||
},
|
||||
"TASKS$TASK_SUGGESTIONS_INFO": {
|
||||
"en": "Task suggestions information",
|
||||
"ja": "タスク提案情報",
|
||||
"zh-CN": "任务建议信息",
|
||||
"zh-TW": "任務建議資訊",
|
||||
"ko-KR": "작업 제안 정보",
|
||||
"no": "Oppgaveforslag informasjon",
|
||||
"it": "Informazioni sui suggerimenti di attività",
|
||||
"pt": "Informações de sugestões de tarefas",
|
||||
"es": "Información de sugerencias de tareas",
|
||||
"ar": "معلومات اقتراحات المهام",
|
||||
"fr": "Informations sur les suggestions de tâches",
|
||||
"tr": "Görev önerisi bilgileri",
|
||||
"de": "Aufgabenvorschlag-Informationen",
|
||||
"uk": "Інформація про пропозиції завдань"
|
||||
},
|
||||
"TASKS$TASK_SUGGESTIONS_TOOLTIP": {
|
||||
"en": "These are AI-curated task suggestions to help you get started with common development activities and best practices for your repository.",
|
||||
"ja": "これらは、リポジトリの一般的な開発活動とベストプラクティスを始めるのに役立つAIによってキュレーションされたタスク提案です。",
|
||||
"zh-CN": "这些是AI策划的任务建议,帮助您开始进行常见的开发活动和存储库的最佳实践。",
|
||||
"zh-TW": "這些是AI策劃的任務建議,幫助您開始進行常見的開發活動和存儲庫的最佳實踐。",
|
||||
"ko-KR": "이것은 저장소의 일반적인 개발 활동과 모범 사례를 시작할 수 있도록 도와주는 AI가 선별한 작업 제안입니다.",
|
||||
"no": "Dette er AI-kuraterte oppgaveforslag som hjelper deg å komme i gang med vanlige utviklingsaktiviteter og beste praksis for ditt depot.",
|
||||
"it": "Questi sono suggerimenti di attività curati dall'IA per aiutarti a iniziare con le attività di sviluppo comuni e le migliori pratiche per il tuo repository.",
|
||||
"pt": "Estas são sugestões de tarefas curadas por IA para ajudá-lo a começar com atividades de desenvolvimento comuns e melhores práticas para seu repositório.",
|
||||
"es": "Estas son sugerencias de tareas curadas por IA para ayudarte a comenzar con actividades de desarrollo comunes y mejores prácticas para tu repositorio.",
|
||||
"ar": "هذه اقتراحات مهام منسقة بواسطة الذكاء الاصطناعي لمساعدتك على البدء بأنشطة التطوير الشائعة وأفضل الممارسات لمستودعك.",
|
||||
"fr": "Ce sont des suggestions de tâches curées par l'IA pour vous aider à commencer avec les activités de développement courantes et les meilleures pratiques pour votre dépôt.",
|
||||
"tr": "Bunlar, deponuz için yaygın geliştirme faaliyetleri ve en iyi uygulamalarla başlamanıza yardımcı olmak için AI tarafından düzenlenmiş görev önerileridir.",
|
||||
"de": "Dies sind KI-kuratierte Aufgabenvorschläge, die Ihnen helfen, mit gängigen Entwicklungsaktivitäten und bewährten Praktiken für Ihr Repository zu beginnen.",
|
||||
"uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію."
|
||||
},
|
||||
"PAYMENT$SPECIFY_AMOUNT_USD": {
|
||||
"en": "Specify an amount in USD to add - min $10",
|
||||
"ja": "追加するUSD金額を指定してください - 最小$10",
|
||||
|
||||
@@ -192,7 +192,7 @@ function AppSettingsScreen() {
|
||||
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-[680px]" // Match the width of the language field
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
function AppContent() {
|
||||
@@ -195,23 +196,25 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
</WsClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function HomeScreen() {
|
||||
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex flex-col md:flex-row justify-between gap-8">
|
||||
<main className="flex flex-col lg:flex-row justify-between gap-8">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function MainApp() {
|
||||
return (
|
||||
<div
|
||||
data-testid="root-layout"
|
||||
className="bg-base p-3 h-screen md:min-w-[1024px] flex flex-col md:flex-row gap-3"
|
||||
className="bg-base p-3 h-screen lg:min-w-[1024px] flex flex-col md:flex-row gap-3"
|
||||
>
|
||||
<Sidebar />
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OpenHandsAction,
|
||||
SystemMessageAction,
|
||||
CommandAction,
|
||||
FinishAction,
|
||||
} from "./actions";
|
||||
import {
|
||||
AgentStateChangeObservation,
|
||||
@@ -15,6 +16,16 @@ import {
|
||||
} from "./observations";
|
||||
import { StatusUpdate } from "./variances";
|
||||
|
||||
export const isOpenHandsEvent = (
|
||||
event: unknown,
|
||||
): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
"source" in event &&
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
export const isOpenHandsAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is OpenHandsAction => "action" in event;
|
||||
@@ -58,7 +69,7 @@ export const isCommandObservation = (
|
||||
|
||||
export const isFinishAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AssistantMessageAction =>
|
||||
): event is FinishAction =>
|
||||
isOpenHandsAction(event) && event.action === "finish";
|
||||
|
||||
export const isSystemMessage = (
|
||||
@@ -76,7 +87,9 @@ export const isMcpObservation = (
|
||||
): event is MCPObservation =>
|
||||
isOpenHandsObservation(event) && event.observation === "mcp";
|
||||
|
||||
export const isStatusUpdate = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is StatusUpdate =>
|
||||
"status_update" in event && "type" in event && "id" in event;
|
||||
export const isStatusUpdate = (event: unknown): event is StatusUpdate =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"status_update" in event &&
|
||||
"type" in event &&
|
||||
"id" in event;
|
||||
|
||||
@@ -35,7 +35,7 @@ interface LocalUserMessageAction {
|
||||
|
||||
export interface StatusUpdate {
|
||||
status_update: true;
|
||||
type: "error";
|
||||
type: "error" | "info";
|
||||
id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
12
frontend/src/types/microagent-status.ts
Normal file
12
frontend/src/types/microagent-status.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum MicroagentStatus {
|
||||
CREATING = "creating",
|
||||
COMPLETED = "completed",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export interface EventMicroagentStatus {
|
||||
eventId: number;
|
||||
conversationId: string;
|
||||
status: MicroagentStatus;
|
||||
prUrl?: string; // Optional PR URL for completed status
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const TOAST_STYLE: CSSProperties = {
|
||||
borderRadius: "4px",
|
||||
};
|
||||
|
||||
const TOAST_OPTIONS: ToastOptions = {
|
||||
export const TOAST_OPTIONS: ToastOptions = {
|
||||
position: "top-right",
|
||||
style: TOAST_STYLE,
|
||||
};
|
||||
|
||||
57
frontend/src/utils/parse-pr-url.ts
Normal file
57
frontend/src/utils/parse-pr-url.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Utility function to parse Pull Request URLs from text
|
||||
*/
|
||||
|
||||
// Common PR URL patterns for different Git providers
|
||||
const PR_URL_PATTERNS = [
|
||||
// GitHub: https://github.com/owner/repo/pull/123
|
||||
/https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/gi,
|
||||
// GitLab: https://gitlab.com/owner/repo/-/merge_requests/123
|
||||
/https?:\/\/gitlab\.com\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
|
||||
// GitLab self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123
|
||||
/https?:\/\/[^/\s]*gitlab[^/\s]*\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
|
||||
// Bitbucket: https://bitbucket.org/owner/repo/pull-requests/123
|
||||
/https?:\/\/bitbucket\.org\/[^/\s]+\/[^/\s]+\/pull-requests\/\d+/gi,
|
||||
// Azure DevOps: https://dev.azure.com/org/project/_git/repo/pullrequest/123
|
||||
/https?:\/\/dev\.azure\.com\/[^/\s]+\/[^/\s]+\/_git\/[^/\s]+\/pullrequest\/\d+/gi,
|
||||
// Generic pattern for other providers that might use /pull/ or /pr/
|
||||
/https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/(?:pull|pr)\/\d+/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts PR URLs from a given text
|
||||
* @param text - The text to search for PR URLs
|
||||
* @returns Array of found PR URLs
|
||||
*/
|
||||
export function extractPRUrls(text: string): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const pattern of PR_URL_PATTERNS) {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
urls.push(...matches);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the text contains any PR URLs
|
||||
* @param text - The text to check
|
||||
* @returns True if PR URLs are found, false otherwise
|
||||
*/
|
||||
export function containsPRUrl(text: string): boolean {
|
||||
return extractPRUrls(text).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the first PR URL found in the text
|
||||
* @param text - The text to search
|
||||
* @returns The first PR URL found, or null if none found
|
||||
*/
|
||||
export function getFirstPRUrl(text: string): string | null {
|
||||
const urls = extractPRUrls(text);
|
||||
return urls.length > 0 ? urls[0] : null;
|
||||
}
|
||||
@@ -12,6 +12,9 @@ if TYPE_CHECKING:
|
||||
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
|
||||
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
|
||||
from openhands.agenthub.codeact_agent.tools.browser import BrowserTool
|
||||
from openhands.agenthub.codeact_agent.tools.condensation_request import (
|
||||
CondensationRequestTool,
|
||||
)
|
||||
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
|
||||
from openhands.agenthub.codeact_agent.tools.ipython import IPythonTool
|
||||
from openhands.agenthub.codeact_agent.tools.llm_based_edit import LLMBasedFileEditTool
|
||||
@@ -119,6 +122,8 @@ class CodeActAgent(Agent):
|
||||
tools.append(ThinkTool)
|
||||
if self.config.enable_finish:
|
||||
tools.append(FinishTool)
|
||||
if self.config.enable_condensation_request:
|
||||
tools.append(CondensationRequestTool)
|
||||
if self.config.enable_browsing:
|
||||
if sys.platform == 'win32':
|
||||
logger.warning('Windows runtime does not support browsing yet')
|
||||
|
||||
@@ -11,6 +11,7 @@ from litellm import (
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools import (
|
||||
BrowserTool,
|
||||
CondensationRequestTool,
|
||||
FinishTool,
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
@@ -35,6 +36,7 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.action.agent import CondensationRequestAction
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
@@ -203,6 +205,12 @@ def response_to_actions(
|
||||
elif tool_call.function.name == ThinkTool['function']['name']:
|
||||
action = AgentThinkAction(thought=arguments.get('thought', ''))
|
||||
|
||||
# ================================================
|
||||
# CondensationRequestAction
|
||||
# ================================================
|
||||
elif tool_call.function.name == CondensationRequestTool['function']['name']:
|
||||
action = CondensationRequestAction()
|
||||
|
||||
# ================================================
|
||||
# BrowserTool
|
||||
# ================================================
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
|
||||
<ROLE>
|
||||
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively. You should be thorough, methodical, and prioritize quality over speed.
|
||||
* If the user asks a question, like "why is X happening", don't try to fix the problem. Just give an answer to the question.
|
||||
</ROLE>
|
||||
|
||||
<EFFICIENCY>
|
||||
* Each action you take is somewhat expensive. Wherever possible, combine multiple actions into a single action, e.g. combine multiple bash commands into one, using sed and grep to edit/view multiple files at once.
|
||||
* When exploring the codebase, use efficient tools like find, grep, and git commands with appropriate filters to minimize unnecessary operations.
|
||||
</EFFICIENCY>
|
||||
|
||||
<FILE_SYSTEM_GUIDELINES>
|
||||
* When a user provides a file path, do NOT assume it's relative to the current working directory. First explore the file system to locate the file before working on it.
|
||||
* If asked to edit a file, edit the file directly, rather than creating a new file with a different filename.
|
||||
* For global search-and-replace operations, consider using `sed` instead of opening file editors multiple times.
|
||||
</FILE_SYSTEM_GUIDELINES>
|
||||
|
||||
<CODE_QUALITY>
|
||||
* Write clean, efficient code with minimal comments. Avoid redundancy in comments: Do not repeat information that can be easily inferred from the code itself.
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
|
||||
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
|
||||
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
|
||||
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
|
||||
</VERSION_CONTROL>
|
||||
|
||||
<PULL_REQUESTS>
|
||||
* When creating pull requests, create only ONE per session/issue unless explicitly instructed otherwise.
|
||||
* When working with an existing PR, update it with new commits rather than creating additional PRs for the same issue.
|
||||
* When updating a PR, preserve the original PR title and purpose, updating description only when necessary.
|
||||
</PULL_REQUESTS>
|
||||
|
||||
<PROBLEM_SOLVING_WORKFLOW>
|
||||
1. EXPLORATION: Thoroughly explore relevant files and understand the context before proposing solutions
|
||||
2. ANALYSIS: Consider multiple approaches and select the most promising one
|
||||
3. TESTING:
|
||||
* For bug fixes: Create tests to verify issues before implementing fixes
|
||||
* For new features: Consider test-driven development when appropriate
|
||||
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
|
||||
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
|
||||
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
|
||||
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
|
||||
</PROBLEM_SOLVING_WORKFLOW>
|
||||
|
||||
<TASK_MANAGEMENT>
|
||||
* For complex, long-horizon tasks, create a TODO.md file to track progress:
|
||||
1. Start by creating a detailed plan in TODO.md with clear steps
|
||||
2. Check TODO.md before each new action to maintain context and track progress
|
||||
3. Update TODO.md as you complete steps or discover new requirements
|
||||
4. Mark completed items with ✓ or [x] to maintain a clear record of progress
|
||||
5. For each major step, add sub-tasks as needed to break down complex work
|
||||
6. If you discover the plan needs significant changes, propose updates and confirm with the user before proceeding and update TODO.md
|
||||
7. IMPORTANT: Do NOT add TODO.md to git commits or version control systems
|
||||
|
||||
* Example TODO.md format:
|
||||
```markdown
|
||||
# Task: [Brief description of the overall task]
|
||||
|
||||
## Plan
|
||||
- [ ] Step 1: [Description]
|
||||
- [ ] Sub-task 1.1
|
||||
- [ ] Sub-task 1.2
|
||||
- [ ] Step 2: [Description]
|
||||
- [x] Step 3: [Description] (Completed)
|
||||
|
||||
## Notes
|
||||
- Important discovery: [Details about something you learned]
|
||||
- Potential issue: [Description of a potential problem]
|
||||
```
|
||||
|
||||
* When working on a task:
|
||||
- Read the README to understand how the system works
|
||||
- Create TODO.md with every major step unchecked
|
||||
- Add TODO.md to .gitignore if it's not already ignored
|
||||
- Until every item in TODO.md is checked:
|
||||
a. Pick the next unchecked item and work on it
|
||||
b. Run appropriate tests to verify your work
|
||||
c. If issues arise, fix them until tests pass
|
||||
d. Once complete, check off the item in TODO.md
|
||||
e. Proceed to the next unchecked item
|
||||
</TASK_MANAGEMENT>
|
||||
|
||||
<SECURITY>
|
||||
* Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect.
|
||||
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
|
||||
</SECURITY>
|
||||
|
||||
<ENVIRONMENT_SETUP>
|
||||
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
|
||||
* If you encounter missing dependencies:
|
||||
1. First, look around in the repository for existing dependency files (requirements.txt, pyproject.toml, package.json, Gemfile, etc.)
|
||||
2. If dependency files exist, use them to install all dependencies at once (e.g., `pip install -r requirements.txt`, `npm install`, etc.)
|
||||
3. Only install individual packages directly if no dependency files are found or if only specific packages are needed
|
||||
* Similarly, if you encounter missing dependencies for essential tools requested by the user, install them when possible.
|
||||
</ENVIRONMENT_SETUP>
|
||||
|
||||
<TROUBLESHOOTING>
|
||||
* If you've made repeated attempts to solve a problem but tests still fail or the user reports it's still broken:
|
||||
1. Step back and reflect on 5-7 different possible sources of the problem
|
||||
2. Assess the likelihood of each possible cause
|
||||
3. Methodically address the most likely causes, starting with the highest probability
|
||||
4. Document your reasoning process
|
||||
* When you run into any major issue while executing a plan from the user, please don't try to directly work around it. Instead, propose a new plan and confirm with the user before proceeding.
|
||||
</TROUBLESHOOTING>
|
||||
@@ -1,5 +1,6 @@
|
||||
from .bash import create_cmd_run_tool
|
||||
from .browser import BrowserTool
|
||||
from .condensation_request import CondensationRequestTool
|
||||
from .finish import FinishTool
|
||||
from .ipython import IPythonTool
|
||||
from .llm_based_edit import LLMBasedFileEditTool
|
||||
@@ -8,6 +9,7 @@ from .think import ThinkTool
|
||||
|
||||
__all__ = [
|
||||
'BrowserTool',
|
||||
'CondensationRequestTool',
|
||||
'create_cmd_run_tool',
|
||||
'FinishTool',
|
||||
'IPythonTool',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_CONDENSATION_REQUEST_DESCRIPTION = 'Request a condensation of the conversation history when the context becomes too long or when you need to focus on the most relevant information.'
|
||||
|
||||
CondensationRequestTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='request_condensation',
|
||||
description=_CONDENSATION_REQUEST_DESCRIPTION,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -28,6 +28,7 @@ from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
)
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.core.schema.exit_reason import ExitReason
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action import (
|
||||
ChangeAgentStateAction,
|
||||
@@ -45,10 +46,11 @@ async def handle_commands(
|
||||
config: OpenHandsConfig,
|
||||
current_dir: str,
|
||||
settings_store: FileSettingsStore,
|
||||
) -> tuple[bool, bool, bool]:
|
||||
) -> tuple[bool, bool, bool, ExitReason]:
|
||||
close_repl = False
|
||||
reload_microagents = False
|
||||
new_session_requested = False
|
||||
exit_reason = ExitReason.ERROR
|
||||
|
||||
if command == '/exit':
|
||||
close_repl = handle_exit_command(
|
||||
@@ -57,6 +59,8 @@ async def handle_commands(
|
||||
usage_metrics,
|
||||
sid,
|
||||
)
|
||||
if close_repl:
|
||||
exit_reason = ExitReason.INTENTIONAL
|
||||
elif command == '/help':
|
||||
handle_help_command()
|
||||
elif command == '/init':
|
||||
@@ -69,6 +73,8 @@ async def handle_commands(
|
||||
close_repl, new_session_requested = handle_new_command(
|
||||
config, event_stream, usage_metrics, sid
|
||||
)
|
||||
if close_repl:
|
||||
exit_reason = ExitReason.INTENTIONAL
|
||||
elif command == '/settings':
|
||||
await handle_settings_command(config, settings_store)
|
||||
elif command == '/resume':
|
||||
@@ -78,7 +84,7 @@ async def handle_commands(
|
||||
action = MessageAction(content=command)
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
|
||||
return close_repl, reload_microagents, new_session_requested
|
||||
return close_repl, reload_microagents, new_session_requested, exit_reason
|
||||
|
||||
|
||||
def handle_exit_command(
|
||||
|
||||
@@ -23,9 +23,10 @@ from openhands.cli.tui import (
|
||||
display_initialization_animation,
|
||||
display_runtime_initialization_message,
|
||||
display_welcome_message,
|
||||
process_agent_pause,
|
||||
read_confirmation_input,
|
||||
read_prompt_input,
|
||||
start_pause_listener,
|
||||
stop_pause_listener,
|
||||
update_streaming_output,
|
||||
)
|
||||
from openhands.cli.utils import (
|
||||
@@ -40,9 +41,11 @@ from openhands.core.config import (
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
|
||||
from openhands.core.config.utils import finalize_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.loop import run_agent_until_done
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.core.schema.exit_reason import ExitReason
|
||||
from openhands.core.setup import (
|
||||
create_agent,
|
||||
create_controller,
|
||||
@@ -77,7 +80,6 @@ async def cleanup_session(
|
||||
controller: AgentController,
|
||||
) -> None:
|
||||
"""Clean up all resources from the current session."""
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
end_state = controller.get_state()
|
||||
end_state.save_to_session(
|
||||
@@ -117,6 +119,7 @@ async def run_session(
|
||||
) -> bool:
|
||||
reload_microagents = False
|
||||
new_session_requested = False
|
||||
exit_reason = ExitReason.INTENTIONAL
|
||||
|
||||
sid = generate_sid(config, session_name)
|
||||
is_loaded = asyncio.Event()
|
||||
@@ -152,7 +155,7 @@ async def run_session(
|
||||
usage_metrics = UsageMetrics()
|
||||
|
||||
async def prompt_for_next_task(agent_state: str) -> None:
|
||||
nonlocal reload_microagents, new_session_requested
|
||||
nonlocal reload_microagents, new_session_requested, exit_reason
|
||||
while True:
|
||||
next_message = await read_prompt_input(
|
||||
config, agent_state, multiline=config.cli_multiline_input
|
||||
@@ -165,6 +168,7 @@ async def run_session(
|
||||
close_repl,
|
||||
reload_microagents,
|
||||
new_session_requested,
|
||||
exit_reason,
|
||||
) = await handle_commands(
|
||||
next_message,
|
||||
event_stream,
|
||||
@@ -183,6 +187,10 @@ async def run_session(
|
||||
display_event(event, config)
|
||||
update_usage_metrics(event, usage_metrics)
|
||||
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state not in [AgentState.RUNNING, AgentState.PAUSED]:
|
||||
await stop_pause_listener()
|
||||
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
@@ -236,9 +244,7 @@ async def run_session(
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
display_agent_running_message()
|
||||
loop.create_task(
|
||||
process_agent_pause(is_paused, event_stream)
|
||||
) # Create a task to track agent pause requests from the user
|
||||
start_pause_listener(loop, is_paused, event_stream)
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
@@ -328,6 +334,11 @@ async def run_session(
|
||||
|
||||
await cleanup_session(loop, agent, runtime, controller)
|
||||
|
||||
if exit_reason == ExitReason.INTENTIONAL:
|
||||
print_formatted_text('✅ Session terminated successfully.\n')
|
||||
else:
|
||||
print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n')
|
||||
|
||||
return new_session_requested
|
||||
|
||||
|
||||
@@ -423,6 +434,10 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
config.workspace_base = os.getcwd()
|
||||
config.security.confirmation_mode = True
|
||||
|
||||
# Need to finalize config again after setting runtime to 'cli'
|
||||
# This ensures Jupyter plugin is disabled for CLI runtime
|
||||
finalize_config(config)
|
||||
|
||||
# TODO: Set working directory from config or use current working directory?
|
||||
current_dir = config.workspace_base
|
||||
|
||||
@@ -434,7 +449,23 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
return
|
||||
|
||||
# Read task from file, CLI args, or stdin
|
||||
task_str = read_task(args, config.cli_multiline_input)
|
||||
if args.file:
|
||||
# For CLI usage, we want to enhance the file content with a prompt
|
||||
# that instructs the agent to read and understand the file first
|
||||
with open(args.file, 'r', encoding='utf-8') as file:
|
||||
file_content = file.read()
|
||||
|
||||
# Create a prompt that instructs the agent to read and understand the file first
|
||||
task_str = f"""The user has tagged a file '{args.file}'.
|
||||
Please read and understand the following file content first:
|
||||
|
||||
```
|
||||
{file_content}
|
||||
```
|
||||
|
||||
After reviewing the file, please ask the user what they would like to do with it."""
|
||||
else:
|
||||
task_str = read_task(args, config.cli_multiline_input)
|
||||
|
||||
# Run the first session
|
||||
new_session_requested = await run_session(
|
||||
@@ -460,7 +491,7 @@ def main():
|
||||
try:
|
||||
loop.run_until_complete(main_with_loop(loop))
|
||||
except KeyboardInterrupt:
|
||||
print('Received keyboard interrupt, shutting down...')
|
||||
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
|
||||
except ConnectionRefusedError as e:
|
||||
print(f'Connection refused: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# CLI Settings are handled separately in cli_settings.py
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -75,6 +76,8 @@ COMMANDS = {
|
||||
|
||||
print_lock = threading.Lock()
|
||||
|
||||
pause_task: asyncio.Task | None = None # No more than one pause task
|
||||
|
||||
|
||||
class UsageMetrics:
|
||||
def __init__(self) -> None:
|
||||
@@ -585,6 +588,28 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
return 'no'
|
||||
|
||||
|
||||
def start_pause_listener(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
done_event: asyncio.Event,
|
||||
event_stream,
|
||||
) -> None:
|
||||
global pause_task
|
||||
if pause_task is None or pause_task.done():
|
||||
pause_task = loop.create_task(
|
||||
process_agent_pause(done_event, event_stream)
|
||||
) # Create a task to track agent pause requests from the user
|
||||
|
||||
|
||||
async def stop_pause_listener() -> None:
|
||||
global pause_task
|
||||
if pause_task and not pause_task.done():
|
||||
pause_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await pause_task
|
||||
await asyncio.sleep(0)
|
||||
pause_task = None
|
||||
|
||||
|
||||
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
|
||||
input = create_input()
|
||||
|
||||
@@ -603,9 +628,12 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
|
||||
)
|
||||
done.set()
|
||||
|
||||
with input.raw_mode():
|
||||
with input.attach(keys_ready):
|
||||
await done.wait()
|
||||
try:
|
||||
with input.raw_mode():
|
||||
with input.attach(keys_ready):
|
||||
await done.wait()
|
||||
finally:
|
||||
input.close()
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
|
||||
@@ -59,7 +59,11 @@ from openhands.events.action import (
|
||||
NullAction,
|
||||
SystemMessageAction,
|
||||
)
|
||||
from openhands.events.action.agent import CondensationAction, RecallAction
|
||||
from openhands.events.action.agent import (
|
||||
CondensationAction,
|
||||
CondensationRequestAction,
|
||||
RecallAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentDelegateObservation,
|
||||
@@ -71,7 +75,6 @@ from openhands.events.observation import (
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.memory.view import View
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
# note: RESUME is only available on web GUI
|
||||
@@ -336,6 +339,8 @@ class AgentController:
|
||||
return True
|
||||
if isinstance(event, CondensationAction):
|
||||
return True
|
||||
if isinstance(event, CondensationRequestAction):
|
||||
return True
|
||||
return False
|
||||
if isinstance(event, Observation):
|
||||
if (
|
||||
@@ -829,7 +834,9 @@ class AgentController:
|
||||
or isinstance(e, ContextWindowExceededError)
|
||||
):
|
||||
if self.agent.config.enable_history_truncation:
|
||||
self._handle_long_context_error()
|
||||
self.event_stream.add_event(
|
||||
CondensationRequestAction(), EventSource.AGENT
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise LLMContextWindowExceedError()
|
||||
@@ -880,7 +887,7 @@ class AgentController:
|
||||
action_id = getattr(action, 'id', 'unknown')
|
||||
action_type = type(action).__name__
|
||||
self.log(
|
||||
'warning',
|
||||
'info',
|
||||
f'Pending action active for {elapsed_time:.2f}s: {action_type} (id={action_id})',
|
||||
extra={'msg_type': 'PENDING_ACTION_TIMEOUT'},
|
||||
)
|
||||
@@ -949,180 +956,6 @@ class AgentController:
|
||||
assert self._closed
|
||||
return self.state_tracker.get_trajectory(include_screenshots)
|
||||
|
||||
def _handle_long_context_error(self) -> None:
|
||||
# When context window is exceeded, keep roughly half of agent interactions
|
||||
current_view = View.from_events(self.state.history)
|
||||
kept_events = self._apply_conversation_window(current_view.events)
|
||||
kept_event_ids = {e.id for e in kept_events}
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'Context window exceeded. Keeping events with IDs: {kept_event_ids}',
|
||||
)
|
||||
|
||||
# The events to forget are those that are not in the kept set
|
||||
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
|
||||
|
||||
if len(kept_event_ids) == 0:
|
||||
self.log(
|
||||
'warning',
|
||||
'No events kept after applying conversation window. This should not happen.',
|
||||
)
|
||||
|
||||
# verify that the first event id in kept_event_ids is the same as the start_id
|
||||
if len(kept_event_ids) > 0 and self.state.history[0].id not in kept_event_ids:
|
||||
self.log(
|
||||
'warning',
|
||||
f'First event after applying conversation window was not kept: {self.state.history[0].id} not in {kept_event_ids}',
|
||||
)
|
||||
|
||||
# Add an error event to trigger another step by the agent
|
||||
self.event_stream.add_event(
|
||||
CondensationAction(
|
||||
forgotten_events_start_id=min(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
forgotten_events_end_id=max(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
),
|
||||
EventSource.AGENT,
|
||||
)
|
||||
|
||||
def _apply_conversation_window(self, history: list[Event]) -> list[Event]:
|
||||
"""Cuts history roughly in half when context window is exceeded.
|
||||
|
||||
It preserves action-observation pairs and ensures that the system message,
|
||||
the first user message, and its associated recall observation are always included
|
||||
at the beginning of the context window.
|
||||
|
||||
The algorithm:
|
||||
1. Identify essential initial events: System Message, First User Message, Recall Observation.
|
||||
2. Determine the slice of recent events to potentially keep.
|
||||
3. Validate the start of the recent slice for dangling observations.
|
||||
4. Combine essential events and validated recent events, ensuring essentials come first.
|
||||
|
||||
Args:
|
||||
events: List of events to filter
|
||||
|
||||
Returns:
|
||||
Filtered list of events keeping newest half while preserving pairs and essential initial events.
|
||||
"""
|
||||
# Handle empty history
|
||||
if not history:
|
||||
return []
|
||||
# 1. Identify essential initial events
|
||||
system_message: SystemMessageAction | None = None
|
||||
first_user_msg: MessageAction | None = None
|
||||
recall_action: RecallAction | None = None
|
||||
recall_observation: Observation | None = None
|
||||
|
||||
# Find System Message (should be the first event, if it exists)
|
||||
system_message = next(
|
||||
(e for e in history if isinstance(e, SystemMessageAction)), None
|
||||
)
|
||||
assert (
|
||||
system_message is None
|
||||
or isinstance(system_message, SystemMessageAction)
|
||||
and system_message.id == history[0].id
|
||||
)
|
||||
|
||||
# Find First User Message in the history, which MUST exist
|
||||
first_user_msg = self._first_user_message(history)
|
||||
if first_user_msg is None:
|
||||
# If not found in history, try the event stream
|
||||
first_user_msg = self._first_user_message()
|
||||
if first_user_msg is None:
|
||||
raise RuntimeError('No first user message found in the event stream.')
|
||||
self.log(
|
||||
'warning',
|
||||
'First user message not found in history. Using cached version from event stream.',
|
||||
)
|
||||
|
||||
# Find the first user message index in the history
|
||||
first_user_msg_index = -1
|
||||
for i, event in enumerate(history):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
first_user_msg_index = i
|
||||
break
|
||||
|
||||
# Find Recall Action and Observation related to the First User Message
|
||||
# Look for RecallAction after the first user message
|
||||
for i in range(first_user_msg_index + 1, len(history)):
|
||||
event = history[i]
|
||||
if (
|
||||
isinstance(event, RecallAction)
|
||||
and event.query == first_user_msg.content
|
||||
):
|
||||
# Found RecallAction, now look for its Observation
|
||||
recall_action = event
|
||||
for j in range(i + 1, len(history)):
|
||||
obs_event = history[j]
|
||||
# Check for Observation caused by this RecallAction
|
||||
if (
|
||||
isinstance(obs_event, Observation)
|
||||
and obs_event.cause == recall_action.id
|
||||
):
|
||||
recall_observation = obs_event
|
||||
break # Found the observation, stop inner loop
|
||||
break # Found the recall action (and maybe obs), stop outer loop
|
||||
|
||||
essential_events: list[Event] = []
|
||||
if system_message:
|
||||
essential_events.append(system_message)
|
||||
# Only include first user message if history is not empty
|
||||
if history:
|
||||
essential_events.append(first_user_msg)
|
||||
# Include recall action and observation if both exist
|
||||
if recall_action and recall_observation:
|
||||
essential_events.append(recall_action)
|
||||
essential_events.append(recall_observation)
|
||||
# Include recall action without observation for backward compatibility
|
||||
elif recall_action:
|
||||
essential_events.append(recall_action)
|
||||
|
||||
# 2. Determine the slice of recent events to potentially keep
|
||||
num_non_essential_events = len(history) - len(essential_events)
|
||||
# Keep roughly half of the non-essential events, minimum 1
|
||||
num_recent_to_keep = max(1, num_non_essential_events // 2)
|
||||
|
||||
# Calculate the starting index for the recent slice
|
||||
slice_start_index = len(history) - num_recent_to_keep
|
||||
slice_start_index = max(0, slice_start_index) # Ensure index is not negative
|
||||
recent_events_slice = history[slice_start_index:]
|
||||
|
||||
# 3. Validate the start of the recent slice for dangling observations
|
||||
# IMPORTANT: Most observations in history are tool call results, which cannot be without their action, or we get an LLM API error
|
||||
first_valid_event_index = 0
|
||||
for i, event in enumerate(recent_events_slice):
|
||||
if isinstance(event, Observation):
|
||||
first_valid_event_index += 1
|
||||
else:
|
||||
break
|
||||
# If all events in the slice are dangling observations, we need to keep at least one
|
||||
if first_valid_event_index == len(recent_events_slice):
|
||||
self.log(
|
||||
'warning',
|
||||
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.',
|
||||
)
|
||||
|
||||
# Adjust the recent_events_slice if dangling observations were found at the start
|
||||
if first_valid_event_index < len(recent_events_slice):
|
||||
validated_recent_events = recent_events_slice[first_valid_event_index:]
|
||||
if first_valid_event_index > 0:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Removed {first_valid_event_index} dangling observation(s) from the start of recent event slice.',
|
||||
)
|
||||
else:
|
||||
validated_recent_events = []
|
||||
|
||||
# 4. Combine essential events and validated recent events
|
||||
events_to_keep: list[Event] = essential_events + validated_recent_events
|
||||
self.log('debug', f'History truncated. Kept {len(events_to_keep)} events.')
|
||||
|
||||
return events_to_keep
|
||||
|
||||
def _is_stuck(self) -> bool:
|
||||
"""Checks if the agent or its delegate is stuck in a loop.
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class AgentConfig(BaseModel):
|
||||
"""Whether to enable think tool"""
|
||||
enable_finish: bool = Field(default=True)
|
||||
"""Whether to enable finish tool"""
|
||||
enable_condensation_request: bool = Field(default=False)
|
||||
"""Whether to enable condensation request tool"""
|
||||
enable_prompt_extensions: bool = Field(default=True)
|
||||
"""Whether to enable prompt extensions"""
|
||||
enable_mcp: bool = Field(default=True)
|
||||
@@ -51,8 +53,7 @@ class AgentConfig(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_toml_section(cls, data: dict) -> dict[str, AgentConfig]:
|
||||
"""
|
||||
Create a mapping of AgentConfig instances from a toml dictionary representing the [agent] section.
|
||||
"""Create a mapping of AgentConfig instances from a toml dictionary representing the [agent] section.
|
||||
|
||||
The default configuration is built from all non-dict keys in data.
|
||||
Then, each key with a dict value is treated as a custom agent configuration, and its values override
|
||||
@@ -70,7 +71,6 @@ class AgentConfig(BaseModel):
|
||||
dict[str, AgentConfig]: A mapping where the key "agent" corresponds to the default configuration
|
||||
and additional keys represent custom configurations.
|
||||
"""
|
||||
|
||||
# Initialize the result mapping
|
||||
agent_mapping: dict[str, AgentConfig] = {}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.core.config.llm_config import LLMConfig
|
||||
class NoOpCondenserConfig(BaseModel):
|
||||
"""Configuration for NoOpCondenser."""
|
||||
|
||||
type: Literal['noop'] = 'noop'
|
||||
type: Literal['noop'] = Field(default='noop')
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
@@ -19,7 +19,7 @@ class NoOpCondenserConfig(BaseModel):
|
||||
class ObservationMaskingCondenserConfig(BaseModel):
|
||||
"""Configuration for ObservationMaskingCondenser."""
|
||||
|
||||
type: Literal['observation_masking'] = 'observation_masking'
|
||||
type: Literal['observation_masking'] = Field(default='observation_masking')
|
||||
attention_window: int = Field(
|
||||
default=100,
|
||||
description='The number of most-recent events where observations will not be masked.',
|
||||
@@ -32,7 +32,7 @@ class ObservationMaskingCondenserConfig(BaseModel):
|
||||
class BrowserOutputCondenserConfig(BaseModel):
|
||||
"""Configuration for the BrowserOutputCondenser."""
|
||||
|
||||
type: Literal['browser_output_masking'] = 'browser_output_masking'
|
||||
type: Literal['browser_output_masking'] = Field(default='browser_output_masking')
|
||||
attention_window: int = Field(
|
||||
default=1,
|
||||
description='The number of most recent browser output observations that will not be masked.',
|
||||
@@ -43,7 +43,7 @@ class BrowserOutputCondenserConfig(BaseModel):
|
||||
class RecentEventsCondenserConfig(BaseModel):
|
||||
"""Configuration for RecentEventsCondenser."""
|
||||
|
||||
type: Literal['recent'] = 'recent'
|
||||
type: Literal['recent'] = Field(default='recent')
|
||||
|
||||
# at least one event by default, because the best guess is that it is the user task
|
||||
keep_first: int = Field(
|
||||
@@ -61,7 +61,7 @@ class RecentEventsCondenserConfig(BaseModel):
|
||||
class LLMSummarizingCondenserConfig(BaseModel):
|
||||
"""Configuration for LLMCondenser."""
|
||||
|
||||
type: Literal['llm'] = 'llm'
|
||||
type: Literal['llm'] = Field(default='llm')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for condensing.'
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class LLMSummarizingCondenserConfig(BaseModel):
|
||||
class AmortizedForgettingCondenserConfig(BaseModel):
|
||||
"""Configuration for AmortizedForgettingCondenser."""
|
||||
|
||||
type: Literal['amortized'] = 'amortized'
|
||||
type: Literal['amortized'] = Field(default='amortized')
|
||||
max_size: int = Field(
|
||||
default=100,
|
||||
description='Maximum size of the condensed history before triggering forgetting.',
|
||||
@@ -108,7 +108,7 @@ class AmortizedForgettingCondenserConfig(BaseModel):
|
||||
class LLMAttentionCondenserConfig(BaseModel):
|
||||
"""Configuration for LLMAttentionCondenser."""
|
||||
|
||||
type: Literal['llm_attention'] = 'llm_attention'
|
||||
type: Literal['llm_attention'] = Field(default='llm_attention')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for attention.'
|
||||
)
|
||||
@@ -131,7 +131,7 @@ class LLMAttentionCondenserConfig(BaseModel):
|
||||
class StructuredSummaryCondenserConfig(BaseModel):
|
||||
"""Configuration for StructuredSummaryCondenser instances."""
|
||||
|
||||
type: Literal['structured'] = 'structured'
|
||||
type: Literal['structured'] = Field(default='structured')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for condensing.'
|
||||
)
|
||||
@@ -156,12 +156,9 @@ class StructuredSummaryCondenserConfig(BaseModel):
|
||||
|
||||
|
||||
class CondenserPipelineConfig(BaseModel):
|
||||
"""Configuration for the CondenserPipeline.
|
||||
"""Configuration for the CondenserPipeline."""
|
||||
|
||||
Not currently supported by the TOML or ENV_VAR configuration strategies.
|
||||
"""
|
||||
|
||||
type: Literal['pipeline'] = 'pipeline'
|
||||
type: Literal['pipeline'] = Field(default='pipeline')
|
||||
condensers: list[CondenserConfig] = Field(
|
||||
default_factory=list,
|
||||
description='List of condenser configurations to be used in the pipeline.',
|
||||
@@ -170,6 +167,17 @@ class CondenserPipelineConfig(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
class ConversationWindowCondenserConfig(BaseModel):
|
||||
"""Configuration for ConversationWindowCondenser.
|
||||
|
||||
Not currently supported by the TOML or ENV_VAR configuration strategies.
|
||||
"""
|
||||
|
||||
type: Literal['conversation_window'] = Field(default='conversation_window')
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
# Type alias for convenience
|
||||
CondenserConfig = (
|
||||
NoOpCondenserConfig
|
||||
@@ -181,14 +189,14 @@ CondenserConfig = (
|
||||
| LLMAttentionCondenserConfig
|
||||
| StructuredSummaryCondenserConfig
|
||||
| CondenserPipelineConfig
|
||||
| ConversationWindowCondenserConfig
|
||||
)
|
||||
|
||||
|
||||
def condenser_config_from_toml_section(
|
||||
data: dict, llm_configs: dict | None = None
|
||||
) -> dict[str, CondenserConfig]:
|
||||
"""
|
||||
Create a CondenserConfig instance from a toml dictionary representing the [condenser] section.
|
||||
"""Create a CondenserConfig instance from a toml dictionary representing the [condenser] section.
|
||||
|
||||
For CondenserConfig, the handling is different since it's a union type. The type of condenser
|
||||
is determined by the 'type' field in the section.
|
||||
@@ -210,7 +218,6 @@ def condenser_config_from_toml_section(
|
||||
Returns:
|
||||
dict[str, CondenserConfig]: A mapping where the key "condenser" corresponds to the configuration.
|
||||
"""
|
||||
|
||||
# Initialize the result mapping
|
||||
condenser_mapping: dict[str, CondenserConfig] = {}
|
||||
|
||||
@@ -261,8 +268,7 @@ from_toml_section = condenser_config_from_toml_section
|
||||
|
||||
|
||||
def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
|
||||
"""
|
||||
Create a CondenserConfig instance based on the specified type.
|
||||
"""Create a CondenserConfig instance based on the specified type.
|
||||
|
||||
Args:
|
||||
condenser_type: The type of condenser to create.
|
||||
@@ -284,6 +290,9 @@ def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
|
||||
'amortized': AmortizedForgettingCondenserConfig,
|
||||
'llm_attention': LLMAttentionCondenserConfig,
|
||||
'structured': StructuredSummaryCondenserConfig,
|
||||
'pipeline': CondenserPipelineConfig,
|
||||
'conversation_window': ConversationWindowCondenserConfig,
|
||||
'browser_output_masking': BrowserOutputCondenserConfig,
|
||||
}
|
||||
|
||||
if condenser_type not in condenser_classes:
|
||||
|
||||
@@ -91,3 +91,6 @@ class ActionType(str, Enum):
|
||||
|
||||
CONDENSATION = 'condensation'
|
||||
"""Condenses a list of events into a summary."""
|
||||
|
||||
CONDENSATION_REQUEST = 'condensation_request'
|
||||
"""Request for condensation of a list of events."""
|
||||
|
||||
7
openhands/core/schema/exit_reason.py
Normal file
7
openhands/core/schema/exit_reason.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
INTENTIONAL = 'intentional'
|
||||
INTERRUPTED = 'interrupted'
|
||||
ERROR = 'error'
|
||||
@@ -195,3 +195,18 @@ class CondensationAction(Action):
|
||||
if self.summary:
|
||||
return f'Summary: {self.summary}'
|
||||
return f'Condenser is dropping the events: {self.forgotten}.'
|
||||
|
||||
|
||||
@dataclass
|
||||
class CondensationRequestAction(Action):
|
||||
"""This action is used to request a condensation of the conversation history.
|
||||
|
||||
Attributes:
|
||||
action (str): The action type, namely ActionType.CONDENSATION_REQUEST.
|
||||
"""
|
||||
|
||||
action: str = ActionType.CONDENSATION_REQUEST
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return 'Requesting a condensation of the conversation history.'
|
||||
|
||||
@@ -9,6 +9,7 @@ from openhands.events.action.agent import (
|
||||
AgentThinkAction,
|
||||
ChangeAgentStateAction,
|
||||
CondensationAction,
|
||||
CondensationRequestAction,
|
||||
RecallAction,
|
||||
)
|
||||
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
|
||||
@@ -43,6 +44,7 @@ actions = (
|
||||
MessageAction,
|
||||
SystemMessageAction,
|
||||
CondensationAction,
|
||||
CondensationRequestAction,
|
||||
MCPAction,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from litellm import completion as litellm_completion
|
||||
from litellm import completion_cost as litellm_completion_cost
|
||||
from litellm.exceptions import (
|
||||
RateLimitError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
from litellm.types.utils import CostPerToken, ModelResponse, Usage
|
||||
from litellm.utils import create_pretrained_tokenizer
|
||||
@@ -40,6 +41,7 @@ __all__ = ['LLM']
|
||||
# tuple of exceptions to retry on
|
||||
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
|
||||
RateLimitError,
|
||||
ServiceUnavailableError,
|
||||
litellm.Timeout,
|
||||
litellm.InternalServerError,
|
||||
LLMNoResponseError,
|
||||
@@ -480,24 +482,26 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p
|
||||
|
||||
# Set the max tokens in an LM-specific way if not set
|
||||
if self.config.max_input_tokens is None:
|
||||
if (
|
||||
self.model_info is not None
|
||||
and 'max_input_tokens' in self.model_info
|
||||
and isinstance(self.model_info['max_input_tokens'], int)
|
||||
):
|
||||
self.config.max_input_tokens = self.model_info['max_input_tokens']
|
||||
else:
|
||||
# Safe fallback for any potentially viable model
|
||||
self.config.max_input_tokens = 4096
|
||||
# Set max_input_tokens from model info if not explicitly set
|
||||
if (
|
||||
self.config.max_input_tokens is None
|
||||
and self.model_info is not None
|
||||
and 'max_input_tokens' in self.model_info
|
||||
and isinstance(self.model_info['max_input_tokens'], int)
|
||||
):
|
||||
self.config.max_input_tokens = self.model_info['max_input_tokens']
|
||||
|
||||
# Set max_output_tokens from model info if not explicitly set
|
||||
if self.config.max_output_tokens is None:
|
||||
# Safe default for any potentially viable model
|
||||
self.config.max_output_tokens = 4096
|
||||
if self.model_info is not None:
|
||||
# max_output_tokens has precedence over max_tokens, if either exists.
|
||||
# litellm has models with both, one or none of these 2 parameters!
|
||||
# Special case for Claude 3.7 Sonnet models
|
||||
if any(
|
||||
model in self.config.model
|
||||
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
|
||||
):
|
||||
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
|
||||
# Try to get from model info
|
||||
elif self.model_info is not None:
|
||||
# max_output_tokens has precedence over max_tokens
|
||||
if 'max_output_tokens' in self.model_info and isinstance(
|
||||
self.model_info['max_output_tokens'], int
|
||||
):
|
||||
@@ -506,11 +510,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
self.model_info['max_tokens'], int
|
||||
):
|
||||
self.config.max_output_tokens = self.model_info['max_tokens']
|
||||
if any(
|
||||
model in self.config.model
|
||||
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
|
||||
):
|
||||
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
|
||||
|
||||
# Initialize function calling capability
|
||||
# Check if model name is in our supported list
|
||||
|
||||
@@ -4,6 +4,9 @@ from openhands.memory.condenser.impl.amortized_forgetting_condenser import (
|
||||
from openhands.memory.condenser.impl.browser_output_condenser import (
|
||||
BrowserOutputCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.conversation_window_condenser import (
|
||||
ConversationWindowCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.llm_attention_condenser import (
|
||||
ImportantEventSelection,
|
||||
LLMAttentionCondenser,
|
||||
@@ -34,4 +37,5 @@ __all__ = [
|
||||
'RecentEventsCondenser',
|
||||
'StructuredSummaryCondenser',
|
||||
'CondenserPipeline',
|
||||
'ConversationWindowCondenser',
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user