From cdd05a98dba5ec5f29ff508f591ecc4e0dc11abd Mon Sep 17 00:00:00 2001 From: tofarr Date: Tue, 8 Oct 2024 07:17:37 -0600 Subject: [PATCH 1/9] Lockup Resiliency and Asyncio Improvements (#4221) --- Makefile | 2 +- openhands/events/stream.py | 15 ++++++++- openhands/llm/async_llm.py | 2 +- openhands/runtime/remote/runtime.py | 6 +++- openhands/runtime/runtime.py | 11 +++++-- openhands/security/invariant/analyzer.py | 7 ++-- openhands/server/listen.py | 9 ++++-- openhands/server/session/agent_session.py | 39 ++++++++--------------- openhands/server/session/session.py | 7 ++-- tests/unit/test_security.py | 13 ++++---- 10 files changed, 62 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index d87ea72296..6c89b04586 100644 --- a/Makefile +++ b/Makefile @@ -195,7 +195,7 @@ start-backend: # Start frontend start-frontend: @echo "$(YELLOW)Starting frontend...$(RESET)" - @cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start + @cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start -- --port $(FRONTEND_PORT) # Common setup for running the app (non-callable) _run_setup: diff --git a/openhands/events/stream.py b/openhands/events/stream.py index 59f7febb87..b667202278 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -129,6 +129,13 @@ class EventStream: del self._subscribers[id] def add_event(self, event: Event, source: EventSource): + try: + asyncio.get_running_loop().create_task(self.async_add_event(event, source)) + except RuntimeError: + # No event loop running... + asyncio.run(self.async_add_event(event, source)) + + async def async_add_event(self, event: Event, source: EventSource): with self._lock: event._id = self._cur_id # type: ignore [attr-defined] self._cur_id += 1 @@ -138,10 +145,16 @@ class EventStream: data = event_to_dict(event) if event.id is not None: self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data)) + tasks = [] for key in sorted(self._subscribers.keys()): stack = self._subscribers[key] callback = stack[-1] - asyncio.create_task(callback(event)) + tasks.append(asyncio.create_task(callback(event))) + if tasks: + await asyncio.wait(tasks) + + def _callback(self, callback: Callable, event: Event): + asyncio.run(callback(event)) def filtered_events_by_source(self, source: EventSource): for event in self.get_events(): diff --git a/openhands/llm/async_llm.py b/openhands/llm/async_llm.py index a467e97b40..fec3de70c2 100644 --- a/openhands/llm/async_llm.py +++ b/openhands/llm/async_llm.py @@ -73,7 +73,7 @@ class AsyncLLM(LLM): and self.config.on_cancel_requested_fn is not None and await self.config.on_cancel_requested_fn() ): - raise UserCancelledError('LLM request cancelled by user') + return await asyncio.sleep(0.1) stop_check_task = asyncio.create_task(check_stopped()) diff --git a/openhands/runtime/remote/runtime.py b/openhands/runtime/remote/runtime.py index a4551e4885..1104270b39 100644 --- a/openhands/runtime/remote/runtime.py +++ b/openhands/runtime/remote/runtime.py @@ -200,6 +200,9 @@ class RemoteRuntime(Runtime): assert ( self.runtime_url is not None ), 'Runtime URL is not set. This should never happen.' + + self._wait_until_alive() + self.send_status_message(' ') self._wait_until_alive() @@ -229,7 +232,7 @@ class RemoteRuntime(Runtime): logger.warning(msg) raise RuntimeError(msg) - def close(self): + def close(self, timeout: int = 10): if self.runtime_id: try: response = send_request( @@ -237,6 +240,7 @@ class RemoteRuntime(Runtime): 'POST', f'{self.config.sandbox.remote_runtime_api_url}/stop', json={'runtime_id': self.runtime_id}, + timeout=timeout, ) if response.status_code != 200: logger.error(f'Failed to stop sandbox: {response.text}') diff --git a/openhands/runtime/runtime.py b/openhands/runtime/runtime.py index 8b293a3da0..efa7373ee5 100644 --- a/openhands/runtime/runtime.py +++ b/openhands/runtime/runtime.py @@ -1,3 +1,4 @@ +import asyncio import atexit import copy import json @@ -117,10 +118,10 @@ class Runtime: if event.timeout is None: event.timeout = self.config.sandbox.timeout assert event.timeout is not None - observation = self.run_action(event) + observation = await self.async_run_action(event) observation._cause = event.id # type: ignore[attr-defined] source = event.source if event.source else EventSource.AGENT - self.event_stream.add_event(observation, source) # type: ignore[arg-type] + await self.event_stream.async_add_event(observation, source) # type: ignore[arg-type] def run_action(self, action: Action) -> Observation: """Run an action and return the resulting observation. @@ -151,6 +152,12 @@ class Runtime: observation = getattr(self, action_type)(action) return observation + async def async_run_action(self, action: Action) -> Observation: + observation = await asyncio.get_event_loop().run_in_executor( + None, self.run_action, action + ) + return observation + # ==================================================================== # Context manager # ==================================================================== diff --git a/openhands/security/invariant/analyzer.py b/openhands/security/invariant/analyzer.py index 0d92f1b327..ed32325d7c 100644 --- a/openhands/security/invariant/analyzer.py +++ b/openhands/security/invariant/analyzer.py @@ -1,3 +1,4 @@ +import asyncio import re import uuid from typing import Any @@ -144,10 +145,8 @@ class InvariantAnalyzer(SecurityAnalyzer): new_event = action_from_dict( {'action': 'change_agent_state', 'args': {'agent_state': 'user_confirmed'}} ) - if event.source: - self.event_stream.add_event(new_event, event.source) - else: - self.event_stream.add_event(new_event, EventSource.AGENT) + event_source = event.source if event.source else EventSource.AGENT + await asyncio.get_event_loop().run_in_executor(None, self.event_stream.add_event, new_event, event_source) async def security_risk(self, event: Action) -> ActionSecurityRisk: logger.info('Calling security_risk on InvariantAnalyzer') diff --git a/openhands/server/listen.py b/openhands/server/listen.py index 2b1144128a..e574d94326 100644 --- a/openhands/server/listen.py +++ b/openhands/server/listen.py @@ -430,7 +430,9 @@ async def list_files(request: Request, path: str | None = None): content={'error': 'Runtime not yet initialized'}, ) runtime: Runtime = request.state.session.agent_session.runtime - file_list = runtime.list_files(path) + file_list = await asyncio.get_event_loop().run_in_executor( + None, runtime.list_files, path + ) if path: file_list = [os.path.join(path, f) for f in file_list] @@ -451,6 +453,7 @@ async def list_files(request: Request, path: str | None = None): return file_list file_list = filter_for_gitignore(file_list, '') + return file_list @@ -478,7 +481,7 @@ async def select_file(file: str, request: Request): file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file) read_action = FileReadAction(file) - observation = runtime.run_action(read_action) + observation = await runtime.async_run_action(read_action) if isinstance(observation, FileReadObservation): content = observation.content @@ -720,7 +723,7 @@ async def save_file(request: Request): runtime.config.workspace_mount_path_in_sandbox, file_path ) write_action = FileWriteAction(file_path, content) - observation = runtime.run_action(write_action) + observation = await runtime.async_run_action(write_action) if isinstance(observation, FileWriteObservation): return JSONResponse( diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index d2db2a6c53..6eb2faa854 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -1,6 +1,4 @@ import asyncio -import concurrent.futures -from threading import Thread from typing import Callable, Optional from openhands.controller import AgentController @@ -32,7 +30,7 @@ class AgentSession: runtime: Runtime | None = None security_analyzer: SecurityAnalyzer | None = None _closed: bool = False - loop: asyncio.AbstractEventLoop + loop: asyncio.AbstractEventLoop | None = None def __init__(self, sid: str, file_store: FileStore): """Initializes a new instance of the Session class @@ -45,7 +43,6 @@ class AgentSession: self.sid = sid self.event_stream = EventStream(sid, file_store) self.file_store = file_store - self.loop = asyncio.new_event_loop() async def start( self, @@ -73,17 +70,9 @@ class AgentSession: 'Session already started. You need to close this session and start a new one.' ) - self.thread = Thread(target=self._run, daemon=True) - self.thread.start() - - def coro_callback(task): - fut: concurrent.futures.Future = concurrent.futures.Future() - try: - fut.set_result(task.result()) - except Exception as e: - logger.error(f'Error starting session: {e}') - - coro = self._start( + asyncio.get_event_loop().run_in_executor( + None, + self._start_thread, runtime_name, config, agent, @@ -93,9 +82,12 @@ class AgentSession: agent_configs, status_message_callback, ) - asyncio.run_coroutine_threadsafe(coro, self.loop).add_done_callback( - coro_callback - ) # type: ignore + + def _start_thread(self, *args): + try: + asyncio.run(self._start(*args), debug=True) + except RuntimeError: + logger.info('Session Finished') async def _start( self, @@ -108,6 +100,7 @@ class AgentSession: agent_configs: dict[str, AgentConfig] | None = None, status_message_callback: Optional[Callable] = None, ): + self.loop = asyncio.get_running_loop() self._create_security_analyzer(config.security.security_analyzer) self._create_runtime(runtime_name, config, agent, status_message_callback) self._create_controller( @@ -125,10 +118,6 @@ class AgentSession: self.controller.agent_task = self.controller.start_step_loop() await self.controller.agent_task # type: ignore - def _run(self): - asyncio.set_event_loop(self.loop) - self.loop.run_forever() - async def close(self): """Closes the Agent session""" @@ -143,10 +132,8 @@ class AgentSession: if self.security_analyzer is not None: await self.security_analyzer.close() - self.loop.call_soon_threadsafe(self.loop.stop) - if self.thread: - # We may be closing an agent_session that was never actually started - self.thread.join() + if self.loop: + self.loop.call_soon_threadsafe(self.loop.stop) self._closed = True diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py index f8cc2b581e..94606d085c 100644 --- a/openhands/server/session/session.py +++ b/openhands/server/session/session.py @@ -162,9 +162,10 @@ class Session: 'Model does not support image upload, change to a different model or try without an image.' ) return - asyncio.run_coroutine_threadsafe( - self._add_event(event, EventSource.USER), self.agent_session.loop - ) # type: ignore + if self.agent_session.loop: + asyncio.run_coroutine_threadsafe( + self._add_event(event, EventSource.USER), self.agent_session.loop + ) # type: ignore async def _add_event(self, event, event_source): self.agent_session.event_stream.add_event(event, EventSource.USER) diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index f4c0503f58..a56e116dd1 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -1,4 +1,3 @@ -import asyncio import pathlib import tempfile @@ -42,7 +41,7 @@ def temp_dir(monkeypatch): yield temp_dir -async def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]]): +def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]]): for event, source in data: event_stream.add_event(event, source) @@ -62,7 +61,7 @@ def test_msg(temp_dir: str): (MessageAction('Hello world!'), EventSource.USER), (MessageAction('ABC!'), EventSource.AGENT), ] - asyncio.run(add_events(event_stream, data)) + add_events(event_stream, data) for i in range(3): assert data[i][0].security_risk == ActionSecurityRisk.LOW assert data[3][0].security_risk == ActionSecurityRisk.MEDIUM @@ -86,7 +85,7 @@ def test_cmd(cmd, expected_risk, temp_dir: str): (MessageAction('Hello world!'), EventSource.USER), (CmdRunAction(cmd), EventSource.USER), ] - asyncio.run(add_events(event_stream, data)) + add_events(event_stream, data) assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == expected_risk @@ -115,7 +114,7 @@ def test_leak_secrets(code, expected_risk, temp_dir: str): (IPythonRunCellAction(code), EventSource.AGENT), (IPythonRunCellAction('hello'), EventSource.AGENT), ] - asyncio.run(add_events(event_stream, data)) + add_events(event_stream, data) assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == expected_risk assert data[2][0].security_risk == ActionSecurityRisk.LOW @@ -133,7 +132,7 @@ def test_unsafe_python_code(temp_dir: str): (MessageAction('Hello world!'), EventSource.USER), (IPythonRunCellAction(code), EventSource.AGENT), ] - asyncio.run(add_events(event_stream, data)) + add_events(event_stream, data) assert data[0][0].security_risk == ActionSecurityRisk.LOW # TODO: this failed but idk why and seems not deterministic to me # assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM @@ -148,7 +147,7 @@ def test_unsafe_bash_command(temp_dir: str): (MessageAction('Hello world!'), EventSource.USER), (CmdRunAction(code), EventSource.AGENT), ] - asyncio.run(add_events(event_stream, data)) + add_events(event_stream, data) assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM From 9d6c1e569d07e8ff3bb1c7d693a6f12c73b3aa0b Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:04:13 +0400 Subject: [PATCH 2/9] fix(frontend): Refactor frontend config (#4261) --- frontend/.env.sample | 5 +- .../file-explorer/FileExplorer.test.tsx | 32 +++--- .../file-explorer/TreeNode.test.tsx | 29 ++--- frontend/src/api/open-hands.ts | 83 +++++++------- frontend/src/api/open-hands.types.ts | 37 +++++++ frontend/src/api/open-hands.utils.ts | 6 ++ .../components/file-explorer/FileExplorer.tsx | 73 +++++++------ .../src/components/file-explorer/TreeNode.tsx | 8 +- .../modals/feedback/FeedbackModal.tsx | 3 +- frontend/src/context/socket.tsx | 10 +- frontend/src/routes/_index/route.tsx | 16 +-- frontend/src/routes/app.tsx | 2 +- frontend/src/routes/oauth.github.callback.tsx | 22 +--- frontend/src/services/fileService.ts | 75 ------------- frontend/vite.config.ts | 101 +++++------------- 15 files changed, 200 insertions(+), 302 deletions(-) create mode 100644 frontend/src/api/open-hands.types.ts create mode 100644 frontend/src/api/open-hands.utils.ts delete mode 100644 frontend/src/services/fileService.ts diff --git a/frontend/.env.sample b/frontend/.env.sample index 217d8c526a..5178426762 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -1,7 +1,4 @@ -VITE_BACKEND_HOST="127.0.0.1:3000" -VITE_USE_TLS="false" -VITE_INSECURE_SKIP_VERIFY="false" -VITE_FRONTEND_PORT="3001" +VITE_BACKEND_BASE_URL="localhost:3000" # Backend URL without protocol (e.g. localhost:3000) # GitHub OAuth VITE_GITHUB_CLIENT_ID="" diff --git a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx index 2d9a5f166b..fcdd05a423 100644 --- a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx +++ b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx @@ -2,24 +2,16 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { describe, it, expect, vi, Mock, afterEach } from "vitest"; -import { uploadFiles, listFiles } from "#/services/fileService"; import toast from "#/utils/toast"; import AgentState from "#/types/AgentState"; import FileExplorer from "#/components/file-explorer/FileExplorer"; +import OpenHands from "#/api/open-hands"; const toastSpy = vi.spyOn(toast, "error"); +const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles"); +const getFilesSpy = vi.spyOn(OpenHands, "getFiles"); vi.mock("../../services/fileService", async () => ({ - listFiles: vi.fn(async (path: string = "/") => { - if (path === "/") { - return Promise.resolve(["folder1/", "file1.ts"]); - } - if (path === "/folder1/" || path === "folder1/") { - return Promise.resolve(["file2.ts"]); - } - return Promise.resolve([]); - }), - uploadFiles: vi.fn(), })); @@ -42,7 +34,7 @@ describe.skip("FileExplorer", () => { expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file1.ts")).toBeInTheDocument(); - expect(listFiles).toHaveBeenCalledTimes(1); // once for root + expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root }); it.todo("should render an empty workspace"); @@ -53,12 +45,12 @@ describe.skip("FileExplorer", () => { expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file1.ts")).toBeInTheDocument(); - expect(listFiles).toHaveBeenCalledTimes(1); // once for root + expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root const refreshButton = screen.getByTestId("refresh"); await user.click(refreshButton); - expect(listFiles).toHaveBeenCalledTimes(2); // once for root, once for refresh button + expect(getFilesSpy).toHaveBeenCalledTimes(2); // once for root, once for refresh button }); it("should toggle the explorer visibility when clicking the toggle button", async () => { @@ -84,15 +76,15 @@ describe.skip("FileExplorer", () => { await user.upload(uploadFileInput, file); // TODO: Improve this test by passing expected argument to `uploadFiles` - expect(uploadFiles).toHaveBeenCalledOnce(); - expect(listFiles).toHaveBeenCalled(); + expect(uploadFilesSpy).toHaveBeenCalledOnce(); + expect(getFilesSpy).toHaveBeenCalled(); const file2 = new File([""], "file-name-2"); const uploadDirInput = await screen.findByTestId("file-input"); await user.upload(uploadDirInput, [file, file2]); - expect(uploadFiles).toHaveBeenCalledTimes(2); - expect(listFiles).toHaveBeenCalled(); + expect(uploadFilesSpy).toHaveBeenCalledTimes(2); + expect(getFilesSpy).toHaveBeenCalled(); }); it.todo("should upload files when dragging them to the explorer", () => { @@ -104,7 +96,7 @@ describe.skip("FileExplorer", () => { it.todo("should download a file"); it("should display an error toast if file upload fails", async () => { - (uploadFiles as Mock).mockRejectedValue(new Error()); + (uploadFilesSpy as Mock).mockRejectedValue(new Error()); const user = userEvent.setup(); renderFileExplorerWithRunningAgentState(); @@ -113,7 +105,7 @@ describe.skip("FileExplorer", () => { await user.upload(uploadFileInput, file); - expect(uploadFiles).rejects.toThrow(); + expect(uploadFilesSpy).rejects.toThrow(); expect(toastSpy).toHaveBeenCalledWith( expect.stringContaining("upload-error"), expect.any(String), diff --git a/frontend/__tests__/components/file-explorer/TreeNode.test.tsx b/frontend/__tests__/components/file-explorer/TreeNode.test.tsx index 2c465fbf80..42919b3660 100644 --- a/frontend/__tests__/components/file-explorer/TreeNode.test.tsx +++ b/frontend/__tests__/components/file-explorer/TreeNode.test.tsx @@ -2,20 +2,13 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { vi, describe, afterEach, it, expect } from "vitest"; -import { selectFile, listFiles } from "#/services/fileService"; import TreeNode from "#/components/file-explorer/TreeNode"; +import OpenHands from "#/api/open-hands"; + +const getFileSpy = vi.spyOn(OpenHands, "getFile"); +const getFilesSpy = vi.spyOn(OpenHands, "getFiles"); vi.mock("../../services/fileService", async () => ({ - listFiles: vi.fn(async (path: string = "/") => { - if (path === "/") { - return Promise.resolve(["folder1/", "file1.ts"]); - } - if (path === "/folder1/" || path === "folder1/") { - return Promise.resolve(["file2.ts"]); - } - return Promise.resolve([]); - }), - selectFile: vi.fn(async () => Promise.resolve({ code: "Hello world!" })), uploadFile: vi.fn(), })); @@ -31,7 +24,7 @@ describe.skip("TreeNode", () => { it("should render a folder if it's in a subdir", async () => { renderWithProviders(); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); @@ -63,27 +56,27 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await user.click(folder1); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(folder1).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); }); - it("should call `selectFile` and return the full path of a file when clicking on a file", async () => { + it("should call `OpenHands.getFile` and return the full path of a file when clicking on a file", async () => { const user = userEvent.setup(); renderWithProviders(); const file2 = screen.getByText("file2.ts"); await user.click(file2); - expect(selectFile).toHaveBeenCalledWith("/folder1/file2.ts"); + expect(getFileSpy).toHaveBeenCalledWith("/folder1/file2.ts"); }); it("should render the full explorer given the defaultOpen prop", async () => { const user = userEvent.setup(); renderWithProviders(); - expect(listFiles).toHaveBeenCalledWith("/"); + expect(getFilesSpy).toHaveBeenCalledWith("/"); const file1 = await screen.findByText("file1.ts"); const folder1 = await screen.findByText("folder1"); @@ -93,7 +86,7 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await user.click(folder1); - expect(listFiles).toHaveBeenCalledWith("folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("folder1/"); expect(file1).toBeInTheDocument(); expect(folder1).toBeInTheDocument(); @@ -109,7 +102,7 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await userEvent.click(folder1); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(folder1).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 8788ec1d15..c881b70a38 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -1,36 +1,20 @@ -interface ErrorResponse { - error: string; -} +import { + SaveFileSuccessResponse, + FileUploadSuccessResponse, + Feedback, + FeedbackResponse, + GitHubAccessTokenResponse, + ErrorResponse, +} from "./open-hands.types"; -interface SaveFileSuccessResponse { - message: string; -} - -interface FileUploadSuccessResponse { - message: string; - uploaded_files: string[]; - skipped_files: { name: string; reason: string }[]; -} - -interface FeedbackBodyResponse { - message: string; - feedback_id: string; - password: string; -} - -interface FeedbackResponse { - statusCode: number; - body: FeedbackBodyResponse; -} - -export interface Feedback { - version: string; - email: string; - token: string; - feedback: "positive" | "negative"; - permissions: "public" | "private"; - trajectory: unknown[]; -} +/** + * Generate the base URL of the OpenHands API + * @returns Base URL of the OpenHands API + */ +const generateBaseURL = () => { + const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000"; + return `http://${baseUrl}`; +}; /** * Class to interact with the OpenHands API @@ -39,7 +23,7 @@ class OpenHands { /** * Base URL of the OpenHands API */ - static BASE_URL = "http://localhost:3000"; + static BASE_URL = generateBaseURL(); /** * Retrieve the list of models available @@ -73,12 +57,17 @@ class OpenHands { /** * Retrieve the list of files available in the workspace * @param token User token provided by the server - * @returns List of files available in the workspace + * @param path Path to list files from + * @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace */ - static async getFiles(token: string): Promise { - const response = await fetch(`${OpenHands.BASE_URL}/api/list-files`, { + static async getFiles(token: string, path?: string): Promise { + const url = new URL(`${OpenHands.BASE_URL}/api/list-files`); + if (path) url.searchParams.append("path", encodeURIComponent(path)); + + const response = await fetch(url.toString(), { headers: OpenHands.generateHeaders(token), }); + return response.json(); } @@ -126,12 +115,12 @@ class OpenHands { * @param file File to upload * @returns Success message or error message */ - static async uploadFile( + static async uploadFiles( token: string, - file: File, + file: File[], ): Promise { const formData = new FormData(); - formData.append("files", file); + file.forEach((f) => formData.append("files", f)); const response = await fetch(`${OpenHands.BASE_URL}/api/upload-files`, { method: "POST", @@ -174,6 +163,22 @@ class OpenHands { return response.json(); } + /** + * Get the GitHub access token + * @param code Code provided by GitHub + * @returns GitHub access token + */ + static async getGitHubAccessToken( + code: string, + ): Promise { + const response = await fetch(`${OpenHands.BASE_URL}/github/callback`, { + method: "POST", + body: JSON.stringify({ code }), + }); + + return response.json(); + } + /** * Generate the headers for the request * @param token User token provided by the server diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts new file mode 100644 index 0000000000..63edc9b034 --- /dev/null +++ b/frontend/src/api/open-hands.types.ts @@ -0,0 +1,37 @@ +export interface ErrorResponse { + error: string; +} + +export interface SaveFileSuccessResponse { + message: string; +} + +export interface FileUploadSuccessResponse { + message: string; + uploaded_files: string[]; + skipped_files: { name: string; reason: string }[]; +} + +export interface FeedbackBodyResponse { + message: string; + feedback_id: string; + password: string; +} + +export interface FeedbackResponse { + statusCode: number; + body: FeedbackBodyResponse; +} + +export interface GitHubAccessTokenResponse { + access_token: string; +} + +export interface Feedback { + version: string; + email: string; + token: string; + feedback: "positive" | "negative"; + permissions: "public" | "private"; + trajectory: unknown[]; +} diff --git a/frontend/src/api/open-hands.utils.ts b/frontend/src/api/open-hands.utils.ts new file mode 100644 index 0000000000..c2460d0636 --- /dev/null +++ b/frontend/src/api/open-hands.utils.ts @@ -0,0 +1,6 @@ +import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types"; + +export const isOpenHandsErrorResponse = ( + data: ErrorResponse | FileUploadSuccessResponse, +): data is ErrorResponse => + typeof data === "object" && data !== null && "error" in data; diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx index 7172dadfd3..8362723b53 100644 --- a/frontend/src/components/file-explorer/FileExplorer.tsx +++ b/frontend/src/components/file-explorer/FileExplorer.tsx @@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next"; import { twMerge } from "tailwind-merge"; import AgentState from "#/types/AgentState"; import { setRefreshID } from "#/state/codeSlice"; -import { uploadFiles } from "#/services/fileService"; import IconButton from "../IconButton"; import ExplorerTree from "./ExplorerTree"; import toast from "#/utils/toast"; @@ -20,6 +19,7 @@ import { RootState } from "#/store"; import { I18nKey } from "#/i18n/declaration"; import OpenHands from "#/api/open-hands"; import { useFiles } from "#/context/files"; +import { isOpenHandsErrorResponse } from "#/api/open-hands.utils"; interface ExplorerActionsProps { onRefresh: () => void; @@ -118,43 +118,46 @@ function FileExplorer() { revalidate(); }; - const uploadFileData = async (toAdd: FileList) => { + const uploadFileData = async (files: FileList) => { try { - const result = await uploadFiles(toAdd); + const token = localStorage.getItem("token"); + if (token) { + const result = await OpenHands.uploadFiles(token, Array.from(files)); - if (result.error) { - // Handle error response - toast.error( - `upload-error-${new Date().getTime()}`, - result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), - ); - return; + if (isOpenHandsErrorResponse(result)) { + // Handle error response + toast.error( + `upload-error-${new Date().getTime()}`, + result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), + ); + return; + } + + const uploadedCount = result.uploaded_files.length; + const skippedCount = result.skipped_files.length; + + if (uploadedCount > 0) { + toast.success( + `upload-success-${new Date().getTime()}`, + t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { + count: uploadedCount, + }), + ); + } + + if (skippedCount > 0) { + const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { + count: skippedCount, + }); + toast.info(message); + } + + if (uploadedCount === 0 && skippedCount === 0) { + toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); + } + + refreshWorkspace(); } - - const uploadedCount = result.uploadedFiles.length; - const skippedCount = result.skippedFiles.length; - - if (uploadedCount > 0) { - toast.success( - `upload-success-${new Date().getTime()}`, - t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { - count: uploadedCount, - }), - ); - } - - if (skippedCount > 0) { - const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { - count: skippedCount, - }); - toast.info(message); - } - - if (uploadedCount === 0 && skippedCount === 0) { - toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); - } - - refreshWorkspace(); } catch (error) { // Handle unexpected errors (network issues, etc.) toast.error( diff --git a/frontend/src/components/file-explorer/TreeNode.tsx b/frontend/src/components/file-explorer/TreeNode.tsx index fed4c4b624..fddcc0288d 100644 --- a/frontend/src/components/file-explorer/TreeNode.tsx +++ b/frontend/src/components/file-explorer/TreeNode.tsx @@ -3,7 +3,6 @@ import { useSelector } from "react-redux"; import { RootState } from "#/store"; import FolderIcon from "../FolderIcon"; import FileIcon from "../FileIcons"; -import { listFiles } from "#/services/fileService"; import OpenHands from "#/api/open-hands"; import { useFiles } from "#/context/files"; import { cn } from "#/utils/utils"; @@ -58,7 +57,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) { setChildren(null); return; } - setChildren(await listFiles(path)); + + const token = localStorage.getItem("token"); + if (token) { + const newChildren = await OpenHands.getFiles(token, path); + setChildren(newChildren); + } }; React.useEffect(() => { diff --git a/frontend/src/components/modals/feedback/FeedbackModal.tsx b/frontend/src/components/modals/feedback/FeedbackModal.tsx index 1149c87865..e7051da694 100644 --- a/frontend/src/components/modals/feedback/FeedbackModal.tsx +++ b/frontend/src/components/modals/feedback/FeedbackModal.tsx @@ -8,7 +8,8 @@ import toast from "#/utils/toast"; import { getToken } from "#/services/auth"; import { removeApiKey, removeUnwantedKeys } from "#/utils/utils"; import { useSocket } from "#/context/socket"; -import OpenHands, { Feedback } from "#/api/open-hands"; +import OpenHands from "#/api/open-hands"; +import { Feedback } from "#/api/open-hands.types"; const isEmailValid = (email: string) => { // Regular expression to validate email format diff --git a/frontend/src/context/socket.tsx b/frontend/src/context/socket.tsx index 523f1e601d..723a6478b9 100644 --- a/frontend/src/context/socket.tsx +++ b/frontend/src/context/socket.tsx @@ -45,15 +45,9 @@ function SocketProvider({ children }: SocketProviderProps) { ); } - /* - const wsUrl = new URL("/", document.baseURI); - wsUrl.protocol = wsUrl.protocol.replace("http", "ws"); - if (options?.token) wsUrl.searchParams.set("token", options.token); - const ws = new WebSocket(`${wsUrl.origin}/ws`); - */ - // TODO: Remove hardcoded URL; may have to use a proxy + const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000"; const ws = new WebSocket( - `ws://localhost:3000/ws${options?.token ? `?token=${options.token}` : ""}`, + `ws://${baseUrl}/ws${options?.token ? `?token=${options.token}` : ""}`, ); ws.addEventListener("open", (event) => { diff --git a/frontend/src/routes/_index/route.tsx b/frontend/src/routes/_index/route.tsx index 0a822671fa..a8c809527f 100644 --- a/frontend/src/routes/_index/route.tsx +++ b/frontend/src/routes/_index/route.tsx @@ -1,5 +1,6 @@ import { ClientActionFunctionArgs, + ClientLoaderFunctionArgs, json, redirect, useLoaderData, @@ -26,10 +27,6 @@ import { removeFile, setInitialQuery } from "#/state/initial-query-slice"; import { clientLoader as rootClientLoader } from "#/root"; import { UploadedFilePreview } from "./uploaded-file-preview"; -const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID; -const redirectUri = "http://localhost:3001/oauth/github/callback"; -const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`; - interface AttachedFilesSliderProps { files: string[]; onRemove: (file: string) => void; @@ -74,7 +71,7 @@ function GitHubAuth({ ); } -export const clientLoader = async () => { +export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { const ghToken = localStorage.getItem("ghToken"); let repositories: GitHubRepository[] = []; if (ghToken) { @@ -84,7 +81,12 @@ export const clientLoader = async () => { } } - return json({ repositories }); + const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID; + const requestUrl = new URL(request.url); + const redirectUri = `${requestUrl.origin}/oauth/github/callback`; + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`; + + return json({ repositories, githubAuthUrl }); }; export const clientAction = async ({ request }: ClientActionFunctionArgs) => { @@ -98,7 +100,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => { function Home() { const rootData = useRouteLoaderData("root"); const navigation = useNavigation(); - const { repositories } = useLoaderData(); + const { repositories, githubAuthUrl } = useLoaderData(); const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = React.useState(false); const [importedFile, setImportedFile] = React.useState(null); diff --git a/frontend/src/routes/app.tsx b/frontend/src/routes/app.tsx index 6ab8028810..b199b6fca7 100644 --- a/frontend/src/routes/app.tsx +++ b/frontend/src/routes/app.tsx @@ -62,7 +62,7 @@ export const clientLoader = async () => { const file = new File([blob], "imported-project.zip", { type: blob.type, }); - await OpenHands.uploadFile(token, file); + await OpenHands.uploadFiles(token, [file]); } if (repo) localStorage.setItem("repo", repo); diff --git a/frontend/src/routes/oauth.github.callback.tsx b/frontend/src/routes/oauth.github.callback.tsx index 90d5ed00b3..582984c708 100644 --- a/frontend/src/routes/oauth.github.callback.tsx +++ b/frontend/src/routes/oauth.github.callback.tsx @@ -4,24 +4,7 @@ import { redirect, useLoaderData, } from "@remix-run/react"; - -const retrieveGitHubAccessToken = async ( - code: string, -): Promise<{ access_token: string }> => { - const response = await fetch("http://localhost:3000/github/callback", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ code }), - }); - - if (!response.ok) { - throw new Error("Failed to retrieve access token"); - } - - return response.json(); -}; +import OpenHands from "#/api/open-hands"; export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { const url = new URL(request.url); @@ -29,7 +12,8 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { if (code) { // request to the server to exchange the code for a token - const { access_token: accessToken } = await retrieveGitHubAccessToken(code); + const { access_token: accessToken } = + await OpenHands.getGitHubAccessToken(code); // set the token in local storage localStorage.setItem("ghToken", accessToken); return redirect("/"); diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts deleted file mode 100644 index 25252235c9..0000000000 --- a/frontend/src/services/fileService.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { request } from "./api"; - -export async function selectFile(file: string): Promise { - const encodedFile = encodeURIComponent(file); - const data = await request(`/api/select-file?file=${encodedFile}`); - return data.code as string; -} - -interface UploadResult { - message: string; - uploadedFiles: string[]; - skippedFiles: Array<{ name: string; reason: string }>; - error?: string; -} - -export async function uploadFiles(files: FileList): Promise { - const formData = new FormData(); - const skippedFiles: Array<{ name: string; reason: string }> = []; - - let uploadedCount = 0; - - for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - - if ( - file.name.includes("..") || - file.name.includes("/") || - file.name.includes("\\") - ) { - skippedFiles.push({ - name: file.name, - reason: "Invalid file name", - }); - } else { - formData.append("files", file); - uploadedCount += 1; - } - } - - formData.append("skippedFilesCount", skippedFiles.length.toString()); - formData.append("uploadedFilesCount", uploadedCount.toString()); - - const response = await request("http://localhost:3000/api/upload-files", { - method: "POST", - body: formData, - }); - - if ( - typeof response.message !== "string" || - !Array.isArray(response.uploaded_files) || - !Array.isArray(response.skipped_files) - ) { - throw new Error("Unexpected response structure from server"); - } - - return { - message: response.message, - uploadedFiles: response.uploaded_files, - skippedFiles: [...skippedFiles, ...response.skipped_files], - }; -} - -export async function listFiles( - path: string | undefined = undefined, -): Promise { - let url = "http://localhost:3000/api/list-files"; - if (path) { - url = `http://localhost:3000/api/list-files?path=${encodeURIComponent(path)}`; - } - const data = await request(url); - if (!Array.isArray(data)) { - throw new Error("Invalid response format: data is not an array"); - } - return data; -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 91ecc01421..30a78092eb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,82 +1,37 @@ /* eslint-disable import/no-extraneous-dependencies */ /// /// -import { defineConfig, loadEnv } from "vite"; +import { defineConfig } from "vite"; import viteTsconfigPaths from "vite-tsconfig-paths"; import svgr from "vite-plugin-svgr"; import { vitePlugin as remix } from "@remix-run/dev"; -export default defineConfig(({ mode }) => { - const { - VITE_BACKEND_HOST = "127.0.0.1:3000", - VITE_USE_TLS = "false", - VITE_FRONTEND_PORT = "3001", - VITE_INSECURE_SKIP_VERIFY = "false", - VITE_WATCH_USE_POLLING = "false", - } = loadEnv(mode, process.cwd()); - - const USE_TLS = VITE_USE_TLS === "true"; - const INSECURE_SKIP_VERIFY = VITE_INSECURE_SKIP_VERIFY === "true"; - const PROTOCOL = USE_TLS ? "https" : "http"; - const WS_PROTOCOL = USE_TLS ? "wss" : "ws"; - - const API_URL = `${PROTOCOL}://${VITE_BACKEND_HOST}/`; - const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`; - const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10); - - // check BACKEND_HOST is something like "example.com" - if (!VITE_BACKEND_HOST.match(/^([\w\d-]+(\.[\w\d-]+)+(:\d+)?)/)) { - throw new Error( - `Invalid BACKEND_HOST ${VITE_BACKEND_HOST}, example BACKEND_HOST 127.0.0.1:3000`, - ); - } - - return { - plugins: [ - !process.env.VITEST && - remix({ - future: { - v3_fetcherPersist: true, - v3_relativeSplatPath: true, - v3_throwAbortReason: true, - }, - appDirectory: "src", - ssr: false, - }), - viteTsconfigPaths(), - svgr(), - ], - ssr: { - noExternal: ["react-syntax-highlighter"], - }, - clearScreen: false, - server: { - watch: { - usePolling: VITE_WATCH_USE_POLLING === "true", - }, - port: FE_PORT, - proxy: { - "/api": { - target: API_URL, - changeOrigin: true, - secure: !INSECURE_SKIP_VERIFY, +export default defineConfig(() => ({ + plugins: [ + !process.env.VITEST && + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, }, - "/ws": { - target: WS_URL, - ws: true, - changeOrigin: true, - secure: !INSECURE_SKIP_VERIFY, - }, - }, + appDirectory: "src", + ssr: false, + }), + viteTsconfigPaths(), + svgr(), + ], + ssr: { + noExternal: ["react-syntax-highlighter"], + }, + clearScreen: false, + test: { + environment: "jsdom", + setupFiles: ["vitest.setup.ts"], + coverage: { + reporter: ["text", "json", "html", "lcov", "text-summary"], + reportsDirectory: "coverage", + include: ["src/**/*.{ts,tsx}"], }, - test: { - environment: "jsdom", - setupFiles: ["vitest.setup.ts"], - coverage: { - reporter: ["text", "json", "html", "lcov", "text-summary"], - reportsDirectory: "coverage", - include: ["src/**/*.{ts,tsx}"], - }, - }, - }; -}); + }, +})); From ef3e1065439b7b82e299967df7be13c4bf8e035e Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:04:30 +0400 Subject: [PATCH 3/9] chore(frontend): Add meta title and description (#4265) --- frontend/src/root.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/root.tsx b/frontend/src/root.tsx index a079c69b05..d73dd48404 100644 --- a/frontend/src/root.tsx +++ b/frontend/src/root.tsx @@ -1,6 +1,7 @@ import { Links, Meta, + MetaFunction, Outlet, Scripts, ScrollRestoration, @@ -49,6 +50,11 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } +export const meta: MetaFunction = () => [ + { title: "OpenHands" }, + { name: "description", content: "Let's Start Building!" }, +]; + export const clientLoader = async () => { let token = localStorage.getItem("token"); const ghToken = localStorage.getItem("ghToken"); From ce18792b12c0cacaa768380341068fbf57fed499 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:05:18 +0400 Subject: [PATCH 4/9] docs(frontend): Update README (#4262) --- frontend/README.md | 148 +++++++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 44 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 5a9a5b9771..7d05e8a9ba 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,63 +1,123 @@ # Getting Started with the OpenHands Frontend -The frontend code can be run against the docker image defined in the [Main README](../README.md) as a backend +## Overview -## Prerequisites +This is the frontend of the OpenHands project. It is a React application that provides a web interface for the OpenHands project. -A recent version of NodeJS / NPM (`brew install node`) +## Tech Stack -## Available Scripts +- Remix SPA Mode (React + Vite + React Router) +- TypeScript +- Redux +- Tailwind CSS +- i18next +- React Testing Library +- Vitest +- Mock Service Worker -In the project directory, you can run: +## Getting Started -### `npm run start -- --port 3001` +### Prerequisites -Runs the app in development mode.\ -Open [http://localhost:3001](http://localhost:3001) to view it in the browser. +- Node.js 20.x or later +- `npm`, `bun`, or any other package manager that supports the `package.json` file -The page will reload if you make edits.\ -You will also see any lint errors in the console. +### Installation -### `npm run make-i18n` +```sh +# Clone the repository +git clone https://github.com/All-Hands-AI/OpenHands.git -Generates the i18n declaration file.\ -Run this when first setting up the repository or when updating translations. +# Change the directory to the frontend +cd OpenHands/frontend -### `npm run test` - -Runs the available test suites for the application.\ -It launches the test runner in interactive watch mode, allowing you to see the results of your tests in real time. - -In order to skip all but one specific test file, like the one for the ChatInterface, the following command might be used: `npm run test -- -t "ChatInterface"` - -### `npm run build` - -Builds the app for production to the `dist` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -## Environment Variables - -You can set the environment variables in `frontend/.env` to configure the frontend. -The following variables are available: - -```javascript -VITE_BACKEND_HOST="127.0.0.1:3000" // The host of the backend -VITE_USE_TLS="false" // Whether to use TLS for the backend (includes HTTPS and WSS) -VITE_INSECURE_SKIP_VERIFY="false" // Whether to skip verifying the backend's certificate. Only takes effect if `VITE_USE_TLS` is true. Don't use this in production! -VITE_FRONTEND_PORT="3001" // The port of the frontend +# Install the dependencies +npm install ``` -You can also set the environment variables from outside the project, like `export VITE_BACKEND_HOST="127.0.0.1:3000"`. +### Running the Application in Development Mode -The outside environment variables will override the ones in the `.env` file. +We use `msw` to mock the backend API. To start the application with the mocked backend, run the following command: -## Learn More +```sh +npm run dev +``` -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +This will start the application in development mode. Open [http://localhost:3001](http://localhost:3001) to view it in the browser. -To learn React, check out the [React documentation](https://reactjs.org/). +**NOTE: The backend is _partially_ mocked using `msw`. Therefore, some features may not work as they would with the actual backend.** -For more information on tests, you can refer to the official documentation of [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/). +### Running the Application with the Actual Backend (Production Mode) + +There are two ways to run the application with the actual backend: + +```sh +# Build the application from the root directory +make build + +# Start the application +make start +``` + +OR + +```sh +# Start the backend from the root directory +make start-backend + +# Build the frontend +cd frontend && npm run build + +# Serve the frontend +npm start -- --port 3001 +``` + +### Environment Variables + +TODO + +### Project Structure + +```sh +frontend +├── __tests__ # Tests +├── public +├── src +│ ├── api # API calls +│ ├── assets +│ ├── components # Reusable components +│ ├── context # Local state management +│ ├── hooks # Custom hooks +│ ├── i18n # Internationalization +│ ├── mocks # MSW mocks for development +│ ├── routes # React Router file-based routes +│ ├── services +│ ├── state # Redux state management +│ ├── types +│ ├── utils # Utility/helper functions +│ └── root.tsx # Entry point +└── .env.sample # Sample environment variables +``` + +### Features + +- Real-time updates with WebSockets +- Internationalization +- Router data loading with Remix +- User authentication with GitHub OAuth (if saas mode is enabled) + +## Testing + +We use `Vitest` for testing. To run the tests, run the following command: + +```sh +npm run test +``` + +## Contributing + +Please read the [CONTRIBUTING.md](../CONTRIBUTING.md) file for details on our code of conduct, and the process for submitting pull requests to us. + +## Troubleshooting + +TODO From ebeda8bcfb70c8c00bf7463943c81808f4ead105 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:05:57 +0400 Subject: [PATCH 5/9] fix(frontend) End session and redirect to main screen if token is invalid (#4263) --- frontend/src/routes/app.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/routes/app.tsx b/frontend/src/routes/app.tsx index b199b6fca7..11200ffed1 100644 --- a/frontend/src/routes/app.tsx +++ b/frontend/src/routes/app.tsx @@ -180,6 +180,11 @@ function App() { return; } + if ("error" in parsed) { + fetcher.submit({}, { method: "POST", action: "/end-session" }); + return; + } + handleAssistantMessage(message.data.toString()); // handle first time connection From 3661893161826c2a36bacdb3b08d12c805134bee Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:06:15 +0400 Subject: [PATCH 6/9] fix(frontend): Set min width so it doesn't squish on smaller screens (#4264) --- frontend/src/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/root.tsx b/frontend/src/root.tsx index d73dd48404..82dc29b54e 100644 --- a/frontend/src/root.tsx +++ b/frontend/src/root.tsx @@ -163,7 +163,7 @@ export default function App() { }; return ( -
+