From 0ea5dcc781911fb635060d40e85c09efd48e807a Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 30 Oct 2024 21:33:42 +0100 Subject: [PATCH 1/7] Remove console leak (#4648) --- openhands/core/logger.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openhands/core/logger.py b/openhands/core/logger.py index 14aff270da9..f6a84669f63 100644 --- a/openhands/core/logger.py +++ b/openhands/core/logger.py @@ -12,9 +12,6 @@ DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 'yes'] if DEBUG: LOG_LEVEL = 'DEBUG' - import litellm - - litellm.set_verbose = True LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'False').lower() in ['true', '1', 'yes'] DISABLE_COLOR_PRINTING = False From c0a0d46eb2fec06469767c877ef338d05c69b54c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 30 Oct 2024 15:34:34 -0500 Subject: [PATCH 2/7] test(runtime) #4623: file permission when running the file_editor (#4628) Co-authored-by: openhands --- tests/runtime/test_ipython.py | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/runtime/test_ipython.py b/tests/runtime/test_ipython.py index afd66e4bd5f..3e22e3e3d3a 100644 --- a/tests/runtime/test_ipython.py +++ b/tests/runtime/test_ipython.py @@ -232,3 +232,85 @@ def test_ipython_package_install(temp_dir, runtime_cls, run_as_openhands): ) _close_test_runtime(runtime) + + +def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls): + """Test file editor permission behavior when running as different users.""" + runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands=True) + sandbox_dir = _get_sandbox_folder(runtime) + + # Create a file owned by root with restricted permissions + action = CmdRunAction( + command='sudo touch /root/test.txt && sudo chmod 600 /root/test.txt' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + # Try to view the file as openhands user - should fail with permission denied + test_code = "print(file_editor(command='view', path='/root/test.txt'))" + action = IPythonRunCellAction(code=test_code) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Permission denied' in obs.content + + # Try to edit the file as openhands user - should fail with permission denied + test_code = "print(file_editor(command='str_replace', path='/root/test.txt', old_str='', new_str='test'))" + action = IPythonRunCellAction(code=test_code) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Permission denied' in obs.content + + # Try to create a file in root directory - should fail with permission denied + test_code = ( + "print(file_editor(command='create', path='/root/new.txt', file_text='test'))" + ) + action = IPythonRunCellAction(code=test_code) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Permission denied' in obs.content + + # Try to use file editor in openhands sandbox directory - should work + test_code = f""" +# Create file +print(file_editor(command='create', path='{sandbox_dir}/test.txt', file_text='Line 1\\nLine 2\\nLine 3')) + +# View file +print(file_editor(command='view', path='{sandbox_dir}/test.txt')) + +# Edit file +print(file_editor(command='str_replace', path='{sandbox_dir}/test.txt', old_str='Line 2', new_str='New Line 2')) + +# Undo edit +print(file_editor(command='undo_edit', path='{sandbox_dir}/test.txt')) +""" + action = IPythonRunCellAction(code=test_code) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'File created successfully' in obs.content + assert 'Line 1' in obs.content + assert 'Line 2' in obs.content + assert 'Line 3' in obs.content + assert 'New Line 2' in obs.content + assert 'Last edit to' in obs.content + assert 'undone successfully' in obs.content + + # Clean up + action = CmdRunAction(command=f'rm -f {sandbox_dir}/test.txt') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + action = CmdRunAction(command='sudo rm -f /root/test.txt') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + _close_test_runtime(runtime) From 87906b96a745b29a5f0bc7cf220cedb65d1cbb42 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 30 Oct 2024 16:42:03 -0400 Subject: [PATCH 3/7] Add job to update PR description with docker run command (#4550) Co-authored-by: openhands --- .github/workflows/ghcr-build.yml | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml index 24e4ef5ac70..6906538a6a8 100644 --- a/.github/workflows/ghcr-build.yml +++ b/.github/workflows/ghcr-build.yml @@ -399,3 +399,49 @@ jobs: run: | echo "Some runtime tests failed or were cancelled" exit 1 + update_pr_description: + name: Update PR Description + if: github.event_name == 'pull_request' + needs: [ghcr_build_runtime] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get short SHA + id: short_sha + run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Update PR Description + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }} + run: | + echo "updating PR description" + DOCKER_RUN_COMMAND="docker run -it --rm \ + -p 3000:3000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --add-host host.docker.internal:host-gateway \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:$SHORT_SHA-nikolaik \ + --name openhands-app-$SHORT_SHA \ + ghcr.io/all-hands-ai/runtime:$SHORT_SHA" + + PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body) + + if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then + UPDATED_PR_BODY=$(echo "${PR_BODY}" | sed -E "s|docker run -it --rm.*|$DOCKER_RUN_COMMAND|") + else + UPDATED_PR_BODY="${PR_BODY} + + --- + + To run this PR locally, use the following command: + \`\`\` + $DOCKER_RUN_COMMAND + \`\`\`" + fi + + echo "updated body: $UPDATED_PR_BODY" + gh pr edit $PR_NUMBER --body "$UPDATED_PR_BODY" From 9c2b48ff5d8f1343fb8a5fd91190b642c96db067 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 30 Oct 2024 16:24:18 -0500 Subject: [PATCH 4/7] fix(eval): SWE-Bench instance with upper-case instance id (#4649) --- evaluation/swe_bench/eval_infer.py | 2 +- evaluation/swe_bench/run_infer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evaluation/swe_bench/eval_infer.py b/evaluation/swe_bench/eval_infer.py index a214f4781ed..c4566c1bfd3 100644 --- a/evaluation/swe_bench/eval_infer.py +++ b/evaluation/swe_bench/eval_infer.py @@ -239,7 +239,7 @@ def process_instance( # Create a directory structure that matches the expected format # NOTE: this is a hack to make the eval report format consistent # with the original SWE-Bench eval script - log_dir = os.path.join(temp_dir, 'logs', instance_id) + log_dir = os.path.join(temp_dir, 'logs', instance_id.lower()) os.makedirs(log_dir, exist_ok=True) test_output_path = os.path.join(log_dir, 'test_output.txt') with open(test_output_path, 'w') as f: diff --git a/evaluation/swe_bench/run_infer.py b/evaluation/swe_bench/run_infer.py index 8b8b45a463e..39163e95943 100644 --- a/evaluation/swe_bench/run_infer.py +++ b/evaluation/swe_bench/run_infer.py @@ -101,7 +101,7 @@ def get_instance_docker_image(instance_id: str) -> str: image_name = image_name.replace( '__', '_s_' ) # to comply with docker image naming convention - return DOCKER_IMAGE_PREFIX.rstrip('/') + '/' + image_name + return (DOCKER_IMAGE_PREFIX.rstrip('/') + '/' + image_name).lower() def get_config( From 4705ef9ec25e4e43d6a1203f0a632b0b05bc6971 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Thu, 31 Oct 2024 07:35:35 -0500 Subject: [PATCH 5/7] chore: do not include "status" dict in share-openhands (#4620) --- frontend/src/utils/utils.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index f60fd779673..6fda98bde63 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -37,21 +37,29 @@ export const removeUnwantedKeys = ( "focused_element_bid", ]; - return data.map((item) => { - // Create a shallow copy of item - const newItem = { ...item }; + return data + .filter((item) => { + // Skip items that have a status key + if ("status" in item) { + return false; + } + return true; + }) + .map((item) => { + // Create a shallow copy of item + const newItem = { ...item }; - // Check if extras exists and delete it from a new extras object - if (newItem.extras) { - const newExtras = { ...newItem.extras }; - UNDESIRED_KEYS.forEach((key) => { - delete newExtras[key as keyof typeof newExtras]; - }); - newItem.extras = newExtras; - } + // Check if extras exists and delete it from a new extras object + if (newItem.extras) { + const newExtras = { ...newItem.extras }; + UNDESIRED_KEYS.forEach((key) => { + delete newExtras[key as keyof typeof newExtras]; + }); + newItem.extras = newExtras; + } - return newItem; - }); + return newItem; + }); }; export const removeApiKey = ( From ce6939fc0d92d93b2456fe2b9407ff326af2f586 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Thu, 31 Oct 2024 09:14:01 -0400 Subject: [PATCH 6/7] Release 0.12.0 - Pending Release Notes Prep (#4650) --- README.md | 6 +++--- docs/modules/usage/how-to/cli-mode.md | 4 ++-- docs/modules/usage/how-to/headless-mode.md | 4 ++-- docs/modules/usage/installation.mdx | 6 +++--- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- pyproject.toml | 4 +--- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c31a6592d7d..39e9e746edf 100644 --- a/README.md +++ b/README.md @@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu system requirements and more information. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.11-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.11-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.11 + docker.all-hands.dev/all-hands-ai/openhands:0.12 ``` You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! diff --git a/docs/modules/usage/how-to/cli-mode.md b/docs/modules/usage/how-to/cli-mode.md index e32da369949..69678054f53 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/usage/how-to/cli-mode.md @@ -50,6 +50,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -58,7 +59,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.11 \ + docker.all-hands.dev/all-hands-ai/openhands:0.12 \ python -m openhands.core.cli ``` @@ -107,4 +108,3 @@ Expected Output: ```bash 🤖 An error occurred. Please try again. ``` - diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index acf883aba1e..6c7f083116b 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -44,6 +44,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -52,7 +53,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.11 \ + docker.all-hands.dev/all-hands-ai/openhands:0.12 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` - diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index 00f5861a118..abb23c58dcd 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -11,15 +11,15 @@ The easiest way to run OpenHands is in Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.11-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.11-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.11 + docker.all-hands.dev/all-hands-ai/openhands:0.12 ``` You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c40099bf89e..5a1f634dfb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { "@monaco-editor/react": "^4.6.0", "@nextui-org/react": "^2.4.8", diff --git a/frontend/package.json b/frontend/package.json index 1f3f94409e1..819c024f042 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.11.0", + "version": "0.12.0", "private": true, "type": "module", "engines": { diff --git a/pyproject.toml b/pyproject.toml index a758e4dcc57..b07fc0aa29a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openhands-ai" -version = "0.11.0" +version = "0.12.0" description = "OpenHands: Code Less, Make More" authors = ["OpenHands"] license = "MIT" @@ -89,7 +89,6 @@ reportlab = "*" [tool.coverage.run] concurrency = ["gevent"] - [tool.poetry.group.runtime.dependencies] jupyterlab = "*" notebook = "*" @@ -120,7 +119,6 @@ ignore = ["D1"] [tool.ruff.lint.pydocstyle] convention = "google" - [tool.poetry.group.evaluation.dependencies] streamlit = "*" whatthepatch = "*" From e17f7b22a63474d0706ff3979b7e766d90b235a6 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Thu, 31 Oct 2024 08:49:47 -0700 Subject: [PATCH 7/7] Remove hidden commands from feedback (#4597) Co-authored-by: Xingyao Wang Co-authored-by: Xingyao Wang Co-authored-by: Graham Neubig --- .../components/feedback-form.test.tsx | 65 +------------- frontend/src/api/open-hands.types.ts | 2 +- frontend/src/components/chat-interface.tsx | 56 +++--------- frontend/src/components/feedback-form.tsx | 88 ++++++++++++++++--- frontend/src/components/feedback-modal.tsx | 76 +--------------- frontend/src/routes/submit-feedback.ts | 47 ---------- openhands/events/stream.py | 16 ++-- openhands/server/data_models/feedback.py | 11 ++- openhands/server/listen.py | 17 +++- 9 files changed, 123 insertions(+), 255 deletions(-) delete mode 100644 frontend/src/routes/submit-feedback.ts diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx index b41fa7e5c77..28684401e2c 100644 --- a/frontend/__tests__/components/feedback-form.test.tsx +++ b/frontend/__tests__/components/feedback-form.test.tsx @@ -5,7 +5,6 @@ import { FeedbackForm } from "#/components/feedback-form"; describe("FeedbackForm", () => { const user = userEvent.setup(); - const onSubmitMock = vi.fn(); const onCloseMock = vi.fn(); afterEach(() => { @@ -13,7 +12,7 @@ describe("FeedbackForm", () => { }); it("should render correctly", () => { - render(); + render(); screen.getByLabelText("Email"); screen.getByLabelText("Private"); @@ -24,7 +23,7 @@ describe("FeedbackForm", () => { }); it("should switch between private and public permissions", async () => { - render(); + render(); const privateRadio = screen.getByLabelText("Private"); const publicRadio = screen.getByLabelText("Public"); @@ -40,69 +39,11 @@ describe("FeedbackForm", () => { expect(publicRadio).not.toBeChecked(); }); - it("should call onSubmit when the form is submitted", async () => { - render(); - const email = screen.getByLabelText("Email"); - - await user.type(email, "test@test.test"); - await user.click(screen.getByRole("button", { name: "Submit" })); - - expect(onSubmitMock).toHaveBeenCalledWith("private", "test@test.test"); // private is the default value - }); - - it("should not call onSubmit when the email is invalid", async () => { - render(); - const email = screen.getByLabelText("Email"); - const submitButton = screen.getByRole("button", { name: "Submit" }); - - await user.click(submitButton); - - expect(onSubmitMock).not.toHaveBeenCalled(); - - await user.type(email, "test"); - await user.click(submitButton); - - expect(onSubmitMock).not.toHaveBeenCalled(); - }); - - it("should submit public permissions when the public radio is checked", async () => { - render(); - const email = screen.getByLabelText("Email"); - const publicRadio = screen.getByLabelText("Public"); - - await user.type(email, "test@test.test"); - await user.click(publicRadio); - await user.click(screen.getByRole("button", { name: "Submit" })); - - expect(onSubmitMock).toHaveBeenCalledWith("public", "test@test.test"); - }); - it("should call onClose when the close button is clicked", async () => { - render(); + render(); await user.click(screen.getByRole("button", { name: "Cancel" })); - expect(onSubmitMock).not.toHaveBeenCalled(); expect(onCloseMock).toHaveBeenCalled(); }); - it("should disable the buttons if isSubmitting is true", () => { - const { rerender } = render( - , - ); - const submitButton = screen.getByRole("button", { name: "Submit" }); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - - expect(submitButton).not.toBeDisabled(); - expect(cancelButton).not.toBeDisabled(); - - rerender( - , - ); - expect(submitButton).toBeDisabled(); - expect(cancelButton).toBeDisabled(); - }); }); diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index ba0e8642bc7..9da1a339b4d 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -31,7 +31,7 @@ export interface Feedback { version: string; email: string; token: string; - feedback: "positive" | "negative"; + polarity: "positive" | "negative"; permissions: "public" | "private"; trajectory: unknown[]; } diff --git a/frontend/src/components/chat-interface.tsx b/frontend/src/components/chat-interface.tsx index 10786c13923..25a57073698 100644 --- a/frontend/src/components/chat-interface.tsx +++ b/frontend/src/components/chat-interface.tsx @@ -1,6 +1,5 @@ import { useDispatch, useSelector } from "react-redux"; import React from "react"; -import { useFetcher } from "@remix-run/react"; import { useSocket } from "#/context/socket"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { ChatMessage } from "./chat-message"; @@ -13,10 +12,6 @@ import { RootState } from "#/store"; import AgentState from "#/types/AgentState"; import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { FeedbackModal } from "./feedback-modal"; -import { Feedback } from "#/api/open-hands.types"; -import { getToken } from "#/services/auth"; -import { removeApiKey, removeUnwantedKeys } from "#/utils/utils"; -import { clientAction } from "#/routes/submit-feedback"; import { useScrollToBottom } from "#/hooks/useScrollToBottom"; import TypingIndicator from "./chat/TypingIndicator"; import ConfirmationButtons from "./chat/ConfirmationButtons"; @@ -24,16 +19,13 @@ import { ErrorMessage } from "./error-message"; import { ContinueButton } from "./continue-button"; import { ScrollToBottomButton } from "./scroll-to-bottom-button"; -const FEEDBACK_VERSION = "1.0"; - const isErrorMessage = ( message: Message | ErrorMessage, ): message is ErrorMessage => "error" in message; export function ChatInterface() { - const { send, events } = useSocket(); + const { send } = useSocket(); const dispatch = useDispatch(); - const fetcher = useFetcher({ key: "feedback" }); const scrollRef = React.useRef(null); const { scrollDomToBottom, onChatBodyScroll, hitBottom } = useScrollToBottom(scrollRef); @@ -44,7 +36,6 @@ export function ChatInterface() { const [feedbackPolarity, setFeedbackPolarity] = React.useState< "positive" | "negative" >("positive"); - const [feedbackShared, setFeedbackShared] = React.useState(0); const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false); const handleSendMessage = async (content: string, files: File[]) => { @@ -71,30 +62,6 @@ export function ChatInterface() { setFeedbackPolarity(polarity); }; - const handleSubmitFeedback = ( - permissions: "private" | "public", - email: string, - ) => { - const feedback: Feedback = { - version: FEEDBACK_VERSION, - feedback: feedbackPolarity, - email, - permissions, - token: getToken(), - trajectory: removeApiKey(removeUnwantedKeys(events)), - }; - - const formData = new FormData(); - formData.append("feedback", JSON.stringify(feedback)); - - fetcher.submit(formData, { - action: "/submit-feedback", - method: "POST", - }); - - setFeedbackShared(messages.length); - }; - return (
- {feedbackShared !== messages.length && messages.length > 3 && ( - - onClickShareFeedbackActionButton("positive") - } - onNegativeFeedback={() => - onClickShareFeedbackActionButton("negative") - } - /> - )} + + onClickShareFeedbackActionButton("positive") + } + onNegativeFeedback={() => + onClickShareFeedbackActionButton("negative") + } + />
{messages.length > 2 && curAgentState === AgentState.AWAITING_USER_INPUT && ( @@ -163,9 +128,8 @@ export function ChatInterface() { setFeedbackModalIsOpen(false)} - onSubmit={handleSubmitFeedback} + polarity={feedbackPolarity} />
); diff --git a/frontend/src/components/feedback-form.tsx b/frontend/src/components/feedback-form.tsx index 7ff03a44307..4e1ddde6354 100644 --- a/frontend/src/components/feedback-form.tsx +++ b/frontend/src/components/feedback-form.tsx @@ -1,27 +1,87 @@ +import React from "react"; +import hotToast from "react-hot-toast"; import ModalButton from "./buttons/ModalButton"; +import { request } from "#/services/api"; +import { Feedback } from "#/api/open-hands.types"; + +const FEEDBACK_VERSION = "1.0"; +const VIEWER_PAGE = "https://www.all-hands.dev/share"; interface FeedbackFormProps { - onSubmit: (permissions: "private" | "public", email: string) => void; onClose: () => void; - isSubmitting?: boolean; + polarity: "positive" | "negative"; } -export function FeedbackForm({ - onSubmit, - onClose, - isSubmitting, -}: FeedbackFormProps) { - const handleSubmit = (event: React.FormEvent) => { +export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const copiedToClipboardToast = () => { + hotToast("Password copied to clipboard", { + icon: "📋", + position: "bottom-right", + }); + }; + + const onPressToast = (password: string) => { + navigator.clipboard.writeText(password); + copiedToClipboardToast(); + }; + + const shareFeedbackToast = ( + message: string, + link: string, + password: string, + ) => { + hotToast( +
+ {message} + onPressToast(password)} + href={link} + target="_blank" + rel="noreferrer" + > + Go to shared feedback + + onPressToast(password)} className="cursor-pointer"> + Password: {password} (copy) + +
, + { duration: 10000 }, + ); + }; + + const handleSubmit = async (event: React.FormEvent) => { event?.preventDefault(); const formData = new FormData(event.currentTarget); + setIsSubmitting(true); + + const email = formData.get("email")?.toString() || ""; + const permissions = (formData.get("permissions")?.toString() || + "private") as "private" | "public"; - const email = formData.get("email")?.toString(); - const permissions = formData.get("permissions")?.toString() as - | "private" - | "public" - | undefined; + const feedback: Feedback = { + version: FEEDBACK_VERSION, + email, + polarity, + permissions, + trajectory: [], + token: "", + }; - if (email) onSubmit(permissions || "private", email); + const response = await request("/api/submit-feedback", { + method: "POST", + body: JSON.stringify(feedback), + headers: { + "Content-Type": "application/json", + }, + }); + const { message, feedback_id, password } = response.body; // eslint-disable-line + const link = `${VIEWER_PAGE}?share_id=${feedback_id}`; + shareFeedbackToast(message, link, password); + setIsSubmitting(false); }; return ( diff --git a/frontend/src/components/feedback-modal.tsx b/frontend/src/components/feedback-modal.tsx index f9cf05f0789..96663135e89 100644 --- a/frontend/src/components/feedback-modal.tsx +++ b/frontend/src/components/feedback-modal.tsx @@ -1,6 +1,4 @@ import React from "react"; -import hotToast, { toast } from "react-hot-toast"; -import { useFetcher } from "@remix-run/react"; import { FeedbackForm } from "./feedback-form"; import { BaseModalTitle, @@ -8,82 +6,18 @@ import { } from "./modals/confirmation-modals/BaseModal"; import { ModalBackdrop } from "./modals/modal-backdrop"; import ModalBody from "./modals/ModalBody"; -import { clientAction } from "#/routes/submit-feedback"; interface FeedbackModalProps { - onSubmit: (permissions: "private" | "public", email: string) => void; onClose: () => void; isOpen: boolean; - isSubmitting?: boolean; + polarity: "positive" | "negative"; } export function FeedbackModal({ - onSubmit, onClose, isOpen, - isSubmitting, + polarity, }: FeedbackModalProps) { - const fetcher = useFetcher({ key: "feedback" }); - const isInitialRender = React.useRef(true); - - const copiedToClipboardToast = () => { - hotToast("Password copied to clipboard", { - icon: "📋", - position: "bottom-right", - }); - }; - - const onPressToast = (password: string) => { - navigator.clipboard.writeText(password); - copiedToClipboardToast(); - }; - - const shareFeedbackToast = ( - message: string, - link: string, - password: string, - ) => { - hotToast( -
- {message} - onPressToast(password)} - href={link} - target="_blank" - rel="noreferrer" - > - Go to shared feedback - - onPressToast(password)} className="cursor-pointer"> - Password: {password} (copy) - -
, - { duration: 10000 }, - ); - }; - - React.useEffect(() => { - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - - // Handle feedback submission - if (fetcher.state === "idle" && fetcher.data) { - if (!fetcher.data.success) { - toast.error("Error submitting feedback"); - } else if (fetcher.data.data) { - const { data } = fetcher.data; - const { message, link, password } = data; - shareFeedbackToast(message, link, password); - } - - onClose(); - } - }, [fetcher.state, fetcher.data?.success]); - if (!isOpen) return null; return ( @@ -91,11 +25,7 @@ export function FeedbackModal({ - + ); diff --git a/frontend/src/routes/submit-feedback.ts b/frontend/src/routes/submit-feedback.ts deleted file mode 100644 index 19281eea7ad..00000000000 --- a/frontend/src/routes/submit-feedback.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ClientActionFunctionArgs, json } from "@remix-run/react"; -import { Feedback } from "#/api/open-hands.types"; -import OpenHands from "#/api/open-hands"; - -const VIEWER_PAGE = "https://www.all-hands.dev/share"; - -const isFeedback = (feedback: unknown): feedback is Feedback => { - if (typeof feedback !== "object" || feedback === null) { - return false; - } - - return ( - "version" in feedback && - "email" in feedback && - "token" in feedback && - "feedback" in feedback && - "permissions" in feedback && - "trajectory" in feedback - ); -}; - -export const clientAction = async ({ request }: ClientActionFunctionArgs) => { - const formData = await request.formData(); - const feedback = formData.get("feedback")?.toString(); - const token = localStorage.getItem("token"); - - if (token && feedback) { - const parsed = JSON.parse(feedback); - if (isFeedback(parsed)) { - try { - const response = await OpenHands.sendFeedback(token, parsed); - if (response.statusCode === 200) { - const { message, feedback_id: feedbackId, password } = response.body; - const link = `${VIEWER_PAGE}?share_id=${feedbackId}`; - return json({ - success: true, - data: { message, link, password }, - }); - } - } catch (error) { - return json({ success: false, data: null }); - } - } - } - - return json({ success: false, data: null }); -}; diff --git a/openhands/events/stream.py b/openhands/events/stream.py index 1e4c3b9d539..aafbcc2fc87 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -71,7 +71,15 @@ def get_events( end_id=None, reverse=False, filter_out_type: tuple[type[Event], ...] | None = None, + filter_hidden=False, ) -> Iterable[Event]: + def should_filter(event: Event): + if filter_hidden and hasattr(event, 'hidden') and event.hidden: + return True + if filter_out_type is not None and isinstance(event, filter_out_type): + return True + return False + if reverse: if end_id is None: end_id = self._cur_id - 1 @@ -79,9 +87,7 @@ def get_events( while event_id >= start_id: try: event = self.get_event(event_id) - if filter_out_type is None or not isinstance( - event, filter_out_type - ): + if not should_filter(event): yield event except FileNotFoundError: logger.debug(f'No event found for ID {event_id}') @@ -93,9 +99,7 @@ def get_events( break try: event = self.get_event(event_id) - if filter_out_type is None or not isinstance( - event, filter_out_type - ): + if not should_filter(event): yield event except FileNotFoundError: break diff --git a/openhands/server/data_models/feedback.py b/openhands/server/data_models/feedback.py index cbdd8744807..59f32008b52 100644 --- a/openhands/server/data_models/feedback.py +++ b/openhands/server/data_models/feedback.py @@ -1,5 +1,5 @@ import json -from typing import Any, Literal +from typing import Any, Literal, Optional import requests from pydantic import BaseModel @@ -10,10 +10,12 @@ class FeedbackDataModel(BaseModel): version: str email: str - token: str - feedback: Literal['positive', 'negative'] + polarity: Literal['positive', 'negative'] + feedback: Literal[ + 'positive', 'negative' + ] # TODO: remove this, its here for backward compatibility permissions: Literal['public', 'private'] - trajectory: list[dict[str, Any]] + trajectory: Optional[list[dict[str, Any]]] FEEDBACK_URL = 'https://share-od-trajectory-3u9bw9tx.uc.gateway.dev/share_od_trajectory' @@ -21,6 +23,7 @@ class FeedbackDataModel(BaseModel): def store_feedback(feedback: FeedbackDataModel) -> dict[str, str]: # Start logging + feedback.feedback = feedback.polarity display_feedback = feedback.model_dump() if 'trajectory' in display_feedback: display_feedback['trajectory'] = ( diff --git a/openhands/server/listen.py b/openhands/server/listen.py index fc740e80293..cc74d5ba736 100644 --- a/openhands/server/listen.py +++ b/openhands/server/listen.py @@ -634,14 +634,14 @@ async def upload_file(request: Request, files: list[UploadFile]): @app.post('/api/submit-feedback') -async def submit_feedback(request: Request, feedback: FeedbackDataModel): +async def submit_feedback(request: Request): """Submit user feedback. This function stores the provided feedback data. To submit feedback: ```sh - curl -X POST -F "email=test@example.com" -F "token=abc" -F "feedback=positive" -F "permissions=private" -F "trajectory={}" http://localhost:3000/api/submit-feedback + curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:" ``` Args: @@ -656,6 +656,19 @@ async def submit_feedback(request: Request, feedback: FeedbackDataModel): """ # Assuming the storage service is already configured in the backend # and there is a function to handle the storage. + body = await request.json() + events = request.state.conversation.event_stream.get_events(filter_hidden=True) + trajectory = [] + for event in events: + trajectory.append(event_to_dict(event)) + feedback = FeedbackDataModel( + email=body.get('email', ''), + version=body.get('version', ''), + permissions=body.get('permissions', 'private'), + polarity=body.get('polarity', ''), + feedback=body.get('polarity', ''), + trajectory=trajectory, + ) try: feedback_data = store_feedback(feedback) return JSONResponse(status_code=200, content=feedback_data)