Skip to content

Commit

Permalink
Add port mappings support (All-Hands-AI#5577)
Browse files Browse the repository at this point in the history
Co-authored-by: openhands <[email protected]>
Co-authored-by: tofarr <[email protected]>
Co-authored-by: Robert Brennan <[email protected]>
Co-authored-by: Robert Brennan <[email protected]>
  • Loading branch information
5 people authored and AlexCuadron committed Jan 13, 2025
1 parent da0006d commit 7aeb6aa
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 33 deletions.
19 changes: 19 additions & 0 deletions frontend/src/components/features/served-host/path-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
interface PathFormProps {
ref: React.RefObject<HTMLFormElement | null>;
onBlur: () => void;
defaultValue: string;
}

export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
return (
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
<input
name="url"
type="text"
defaultValue={defaultValue}
className="w-full bg-transparent"
onBlur={onBlur}
/>
</form>
);
}
12 changes: 12 additions & 0 deletions frontend/src/components/layout/served-app-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useActiveHost } from "#/hooks/query/use-active-host";

export function ServedAppLabel() {
const { activeHost } = useActiveHost();

return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">App</div>
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
</div>
);
}
5 changes: 4 additions & 1 deletion frontend/src/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ async function prepareApp() {
}
}

const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!query.queryKey.includes("authenticated")) toast.error(error.message);
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/hooks/query/use-active-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import { useSelector } from "react-redux";
import { openHands } from "#/api/open-hands-axios";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RootState } from "#/store";
import { useConversation } from "#/context/conversation-context";

export const useActiveHost = () => {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [activeHost, setActiveHost] = React.useState<string | null>(null);

const { conversationId } = useConversation();

const { data } = useQuery({
queryKey: [conversationId, "hosts"],
queryFn: async () => {
const response = await openHands.get<{ hosts: string[] }>(
`/api/conversations/${conversationId}/web-hosts`,
);
return { hosts: Object.keys(response.data.hosts) };
},
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
initialData: { hosts: [] },
});

const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
})),
});

const appsData = apps.map((app) => app.data);

React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);

return { activeHost };
};
1 change: 1 addition & 0 deletions frontend/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default [
index("routes/_oh.app._index/route.tsx"),
route("browser", "routes/_oh.app.browser.tsx"),
route("jupyter", "routes/_oh.app.jupyter.tsx"),
route("served", "routes/app.tsx"),
]),
]),

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/_oh.app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import {
ConversationProvider,
Expand Down Expand Up @@ -31,6 +32,7 @@ import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { CountBadge } from "#/components/layout/count-badge";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
Expand Down Expand Up @@ -126,6 +128,11 @@ function AppContent() {
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/routes/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from "react";
import { FaArrowRotateRight } from "react-icons/fa6";
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
import { useActiveHost } from "#/hooks/query/use-active-host";
import { PathForm } from "#/components/features/served-host/path-form";

function ServedApp() {
const { activeHost } = useActiveHost();
const [refreshKey, setRefreshKey] = React.useState(0);
const [currentActiveHost, setCurrentActiveHost] = React.useState<
string | null
>(null);
const [path, setPath] = React.useState<string>("hello");

const formRef = React.useRef<HTMLFormElement>(null);

const handleOnBlur = () => {
if (formRef.current) {
const formData = new FormData(formRef.current);
const urlInputValue = formData.get("url")?.toString();

if (urlInputValue) {
const url = new URL(urlInputValue);

setCurrentActiveHost(url.origin);
setPath(url.pathname);
}
}
};

const resetUrl = () => {
setCurrentActiveHost(activeHost);
setPath("");

if (formRef.current) {
formRef.current.reset();
}
};

React.useEffect(() => {
resetUrl();
}, [activeHost]);

const fullUrl = `${currentActiveHost}/${path}`;

if (!currentActiveHost) {
return (
<div className="flex items-center justify-center w-full h-full p-10">
<span className="text-neutral-400 font-bold">
If you tell OpenHands to start a web server, the app will appear here.
</span>
</div>
);
}

return (
<div className="h-full w-full">
<div className="w-full p-2 flex items-center gap-4 border-b border-neutral-600">
<button
type="button"
onClick={() => window.open(fullUrl, "_blank")}
className="text-sm"
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setRefreshKey((prev) => prev + 1)}
className="text-sm"
>
<FaArrowRotateRight className="w-4 h-4" />
</button>

<button type="button" onClick={() => resetUrl()} className="text-sm">
<FaHome className="w-4 h-4" />
</button>
<div className="w-full flex">
<PathForm
ref={formRef}
onBlur={handleOnBlur}
defaultValue={fullUrl}
/>
</div>
</div>
<iframe
key={refreshKey}
title="Served App"
src={fullUrl}
className="w-full h-full"
/>
</div>
);
}

export default ServedApp;
14 changes: 13 additions & 1 deletion openhands/agenthub/codeact_agent/prompts/system_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "[email protected]" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
{{ runtime_info }}
</IMPORTANT>
{% if repo_instructions %}
{% if repo_instructions -%}
<REPOSITORY_INSTRUCTIONS>
{{ repo_instructions }}
</REPOSITORY_INSTRUCTIONS>
{% endif %}
{% if runtime_info and runtime_info.available_hosts -%}
<RUNTIME_INFORMATION>
The user has access to the following hosts for accessing a web application,
each of which has a corresponding port:
{% for host, port in runtime_info.available_hosts.items() -%}
* {{ host }} (port {{ port }})
{% endfor %}
When starting a web server, use the corresponding ports. You should also
set any options to allow iframes and CORS requests.
</RUNTIME_INFORMATION>
{% endif %}
3 changes: 0 additions & 3 deletions openhands/runtime/action_execution_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system import check_port_available
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all

Expand Down Expand Up @@ -435,8 +434,6 @@ def close(self):
)
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
assert check_port_available(int(os.environ['VSCODE_PORT']))

plugins_to_load: list[Plugin] = []
if args.plugins:
Expand Down
4 changes: 4 additions & 0 deletions openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,7 @@ def vscode_enabled(self) -> bool:
@property
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')

@property
def web_hosts(self) -> dict[str, int]:
return {}
Loading

0 comments on commit 7aeb6aa

Please sign in to comment.