Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add port mappings support #5577

Merged
merged 61 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
e41ca72
Add port mappings support to sandbox config
openhands-agent Dec 13, 2024
c7d3225
Add UI for served app
amanape Dec 13, 2024
4bc7721
Merge branch 'main' into add-port-mappings
amanape Dec 16, 2024
291c496
Create new property
amanape Dec 16, 2024
9cee2c3
Merge branch 'main' into add-port-mappings
amanape Dec 17, 2024
42876f3
Multi port support and extend base and remote runtimes
amanape Dec 17, 2024
a84f7e6
Merge and resolve
amanape Dec 17, 2024
395527d
Adjustment and changes
amanape Dec 17, 2024
e68a658
Small edit
amanape Dec 17, 2024
68215db
Add runtime-specific port mapping instructions to agent prompt
openhands-agent Dec 18, 2024
c03c1ad
Fix template modification to use file reading instead of non-existent…
openhands-agent Dec 18, 2024
468b61c
Move prompt extension logic to PromptManager class
openhands-agent Dec 18, 2024
7b4628b
Some fixes and improvements
amanape Dec 18, 2024
80d92ae
Merge and resolve
amanape Dec 19, 2024
d3018c2
Improve jinja template handling
amanape Dec 19, 2024
4b9a65a
Fix comments
amanape Dec 19, 2024
0a23fd9
Mostly renaming
amanape Dec 19, 2024
65a419f
Merge and resolve
amanape Dec 19, 2024
b1af1db
Address prompt handling
amanape Dec 19, 2024
75ca147
Merge branch 'main' into add-port-mappings
amanape Dec 20, 2024
0e700d1
Merge and resolve
amanape Dec 20, 2024
0cef034
Address comments
amanape Dec 20, 2024
eac950a
Update instructions
amanape Dec 20, 2024
301231b
Merge, resolve, and fix error
amanape Dec 23, 2024
c55fa09
Fix error
amanape Dec 23, 2024
c5b7382
merge and resolve
amanape Dec 27, 2024
a0d2efd
Rename
amanape Dec 27, 2024
30f0b49
Merge branch 'main' into add-port-mappings
tofarr Dec 27, 2024
6a3306e
Lint fix
tofarr Dec 27, 2024
7aa527d
merge
amanape Dec 31, 2024
020df3c
Merge branch 'main' into add-port-mappings
rbren Jan 1, 2025
ac973fa
make prompting clearer
rbren Jan 1, 2025
ff07644
handle errors
rbren Jan 1, 2025
0130246
fix llm err handling
rbren Jan 1, 2025
6ac55ca
Merge branch 'rb/fix-errs' into add-port-mappings
rbren Jan 1, 2025
d9814cf
json for dict configs
rbren Jan 1, 2025
a2ae2d9
fix prompt
rbren Jan 1, 2025
37c671a
dynamic port mapping
rbren Jan 1, 2025
8f67d2d
fix ports
rbren Jan 1, 2025
108bce0
fix port initialization
rbren Jan 1, 2025
6ebdf16
remove offline label
rbren Jan 1, 2025
6d078f8
fix up iframe
rbren Jan 1, 2025
3c6d5f8
change web_hosts api
rbren Jan 1, 2025
7401215
fix frontend for new api
rbren Jan 1, 2025
5122f83
delint
rbren Jan 1, 2025
aaa0b22
delint
rbren Jan 1, 2025
33cbc1e
change to work_hosts
rbren Jan 1, 2025
182071d
remove log
rbren Jan 1, 2025
6bf627d
fix vscode
rbren Jan 1, 2025
5ba5766
Merge branch 'main' into add-port-mappings
amanape Jan 2, 2025
282f5b4
Add refresh button and small active indicator
amanape Jan 2, 2025
c98ceca
Allow editing path and create redirect button
amanape Jan 2, 2025
c951c36
Merge branch 'main' into add-port-mappings
amanape Jan 2, 2025
ea7691b
Allow to edit full URL
amanape Jan 2, 2025
cfcac55
Resolve merge conflicts with main
openhands-agent Jan 3, 2025
a99fb0e
Apply pre-commit fixes
openhands-agent Jan 3, 2025
c2ab895
Merge branch 'main' into add-port-mappings
rbren Jan 6, 2025
5d51a35
change app port ranges
rbren Jan 8, 2025
99489d7
Merge branch 'main' into add-port-mappings
rbren Jan 8, 2025
1854883
Merge branch 'main' into add-port-mappings
amanape Jan 9, 2025
ae92c8f
Update openhands/runtime/impl/docker/docker_runtime.py
rbren Jan 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -32,6 +33,7 @@ import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
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_CONVO_UI_IS_ENABLED } from "#/utils/constants";
Expand Down Expand Up @@ -139,6 +141,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 @@ -421,8 +420,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 @@ -398,3 +398,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
Loading