mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
Merge branch 'main' into mh/rel060
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
|||||||
V1AppConversationStartTask,
|
V1AppConversationStartTask,
|
||||||
V1AppConversationStartTaskPage,
|
V1AppConversationStartTaskPage,
|
||||||
V1AppConversation,
|
V1AppConversation,
|
||||||
|
V1SandboxInfo,
|
||||||
} from "./v1-conversation-service.types";
|
} from "./v1-conversation-service.types";
|
||||||
|
|
||||||
class V1ConversationService {
|
class V1ConversationService {
|
||||||
@@ -268,6 +269,32 @@ class V1ConversationService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch get V1 sandboxes by their IDs
|
||||||
|
* Returns null for any missing sandboxes
|
||||||
|
*
|
||||||
|
* @param ids Array of sandbox IDs (max 100)
|
||||||
|
* @returns Array of sandboxes or null for missing ones
|
||||||
|
*/
|
||||||
|
static async batchGetSandboxes(
|
||||||
|
ids: string[],
|
||||||
|
): Promise<(V1SandboxInfo | null)[]> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (ids.length > 100) {
|
||||||
|
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
ids.forEach((id) => params.append("id", id));
|
||||||
|
|
||||||
|
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||||
|
`/api/v1/sandboxes?${params.toString()}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a single file to the V1 conversation workspace
|
* Upload a single file to the V1 conversation workspace
|
||||||
* V1 API endpoint: POST /api/file/upload/{path}
|
* V1 API endpoint: POST /api/file/upload/{path}
|
||||||
|
|||||||
@@ -98,3 +98,18 @@ export interface V1AppConversation {
|
|||||||
conversation_url: string | null;
|
conversation_url: string | null;
|
||||||
session_api_key: string | null;
|
session_api_key: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface V1ExposedUrl {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V1SandboxInfo {
|
||||||
|
id: string;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
sandbox_spec_id: string;
|
||||||
|
status: V1SandboxStatus;
|
||||||
|
session_api_key: string | null;
|
||||||
|
exposed_urls: V1ExposedUrl[] | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||||
|
|
||||||
|
export const useBatchAppConversations = (ids: string[]) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["v1-batch-get-app-conversations", ids],
|
||||||
|
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
|
||||||
|
enabled: ids.length > 0,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||||
|
});
|
||||||
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||||
|
|
||||||
|
export const useBatchSandboxes = (ids: string[]) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["sandboxes", "batch", ids],
|
||||||
|
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
|
||||||
|
enabled: ids.length > 0,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||||
|
});
|
||||||
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import React from "react";
|
||||||
|
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||||
|
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||||
|
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||||
|
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||||
|
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||||
|
import { useConversationConfig } from "./use-conversation-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified hook to get active web host for both legacy (V0) and V1 conversations
|
||||||
|
* - V0: Uses the legacy getWebHosts API endpoint and polls them
|
||||||
|
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
|
||||||
|
*/
|
||||||
|
export const useUnifiedActiveHost = () => {
|
||||||
|
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||||
|
const { conversationId } = useConversationId();
|
||||||
|
const runtimeIsReady = useRuntimeIsReady();
|
||||||
|
const { data: conversation } = useActiveConversation();
|
||||||
|
const { data: conversationConfig, isLoading: isLoadingConfig } =
|
||||||
|
useConversationConfig();
|
||||||
|
|
||||||
|
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||||
|
const sandboxId = conversationConfig?.runtime_id;
|
||||||
|
|
||||||
|
// Fetch sandbox data for V1 conversations
|
||||||
|
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||||
|
|
||||||
|
// Get worker URLs from V1 sandbox or legacy web hosts from V0
|
||||||
|
const { data, isLoading: hostsQueryLoading } = useQuery({
|
||||||
|
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
|
||||||
|
queryFn: async () => {
|
||||||
|
// V1: Get worker URLs from sandbox exposed_urls
|
||||||
|
if (isV1Conversation) {
|
||||||
|
if (
|
||||||
|
!sandboxesQuery.data ||
|
||||||
|
sandboxesQuery.data.length === 0 ||
|
||||||
|
!sandboxesQuery.data[0]
|
||||||
|
) {
|
||||||
|
return { hosts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbox = sandboxesQuery.data[0];
|
||||||
|
const workerUrls =
|
||||||
|
sandbox.exposed_urls
|
||||||
|
?.filter((url) => url.name.startsWith("WORKER_"))
|
||||||
|
.map((url) => url.url) || [];
|
||||||
|
|
||||||
|
return { hosts: workerUrls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// V0 (Legacy): Use the legacy API endpoint
|
||||||
|
const hosts = await ConversationService.getWebHosts(conversationId);
|
||||||
|
return { hosts };
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
runtimeIsReady &&
|
||||||
|
!!conversationId &&
|
||||||
|
(!isV1Conversation || !!sandboxesQuery.data),
|
||||||
|
initialData: { hosts: [] },
|
||||||
|
meta: {
|
||||||
|
disableToast: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll all hosts to find which one is active
|
||||||
|
const apps = useQueries({
|
||||||
|
queries: data.hosts.map((host) => ({
|
||||||
|
queryKey: [conversationId, "unified", "hosts", host],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
await axios.get(host);
|
||||||
|
return host;
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchInterval: 3000,
|
||||||
|
meta: {
|
||||||
|
disableToast: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const appsData = apps.map((app) => app.data);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const successfulApp = appsData.find((app) => app);
|
||||||
|
setActiveHost(successfulApp || "");
|
||||||
|
}, [appsData]);
|
||||||
|
|
||||||
|
// Calculate overall loading state including dependent queries for V1
|
||||||
|
const isLoading = isV1Conversation
|
||||||
|
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
|
||||||
|
: hostsQueryLoading;
|
||||||
|
|
||||||
|
return { activeHost, isLoading };
|
||||||
|
};
|
||||||
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||||
|
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||||
|
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||||
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||||
|
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||||
|
import { useBatchAppConversations } from "./use-batch-app-conversations";
|
||||||
|
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||||
|
|
||||||
|
interface VSCodeUrlResult {
|
||||||
|
url: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
|
||||||
|
* - V0: Uses the legacy getVSCodeUrl API endpoint
|
||||||
|
* - V1: Gets the VSCode URL from sandbox exposed_urls
|
||||||
|
*/
|
||||||
|
export const useUnifiedVSCodeUrl = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { conversationId } = useConversationId();
|
||||||
|
const { data: conversation } = useActiveConversation();
|
||||||
|
const runtimeIsReady = useRuntimeIsReady();
|
||||||
|
|
||||||
|
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||||
|
|
||||||
|
// Fetch V1 app conversation to get sandbox_id
|
||||||
|
const appConversationsQuery = useBatchAppConversations(
|
||||||
|
isV1Conversation && conversationId ? [conversationId] : [],
|
||||||
|
);
|
||||||
|
const appConversation = appConversationsQuery.data?.[0];
|
||||||
|
const sandboxId = appConversation?.sandbox_id;
|
||||||
|
|
||||||
|
// Fetch sandbox data for V1 conversations
|
||||||
|
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||||
|
|
||||||
|
const mainQuery = useQuery<VSCodeUrlResult>({
|
||||||
|
queryKey: [
|
||||||
|
"unified",
|
||||||
|
"vscode_url",
|
||||||
|
conversationId,
|
||||||
|
isV1Conversation,
|
||||||
|
sandboxId,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!conversationId) throw new Error("No conversation ID");
|
||||||
|
|
||||||
|
// V1: Get VSCode URL from sandbox exposed_urls
|
||||||
|
if (isV1Conversation) {
|
||||||
|
if (
|
||||||
|
!sandboxesQuery.data ||
|
||||||
|
sandboxesQuery.data.length === 0 ||
|
||||||
|
!sandboxesQuery.data[0]
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbox = sandboxesQuery.data[0];
|
||||||
|
const vscodeUrl = sandbox.exposed_urls?.find(
|
||||||
|
(url) => url.name === "VSCODE",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!vscodeUrl) {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: transformVSCodeUrl(vscodeUrl.url),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// V0 (Legacy): Use the legacy API endpoint
|
||||||
|
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||||
|
|
||||||
|
if (data.vscode_url) {
|
||||||
|
return {
|
||||||
|
url: transformVSCodeUrl(data.vscode_url),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
runtimeIsReady &&
|
||||||
|
!!conversationId &&
|
||||||
|
(!isV1Conversation || !!sandboxesQuery.data),
|
||||||
|
refetchOnMount: true,
|
||||||
|
retry: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate overall loading state including dependent queries for V1
|
||||||
|
const isLoading = isV1Conversation
|
||||||
|
? appConversationsQuery.isLoading ||
|
||||||
|
sandboxesQuery.isLoading ||
|
||||||
|
mainQuery.isLoading
|
||||||
|
: mainQuery.isLoading;
|
||||||
|
|
||||||
|
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
|
||||||
|
return {
|
||||||
|
data: mainQuery.data,
|
||||||
|
error: mainQuery.error,
|
||||||
|
isLoading,
|
||||||
|
isError: mainQuery.isError,
|
||||||
|
isSuccess: mainQuery.isSuccess,
|
||||||
|
status: mainQuery.status,
|
||||||
|
refetch: mainQuery.refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,14 +2,14 @@ import React from "react";
|
|||||||
import { FaArrowRotateRight } from "react-icons/fa6";
|
import { FaArrowRotateRight } from "react-icons/fa6";
|
||||||
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
|
||||||
import { PathForm } from "#/components/features/served-host/path-form";
|
import { PathForm } from "#/components/features/served-host/path-form";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||||
|
|
||||||
function ServedApp() {
|
function ServedApp() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeHost } = useActiveHost();
|
const { activeHost } = useUnifiedActiveHost();
|
||||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||||
import { useAgentState } from "#/hooks/use-agent-state";
|
import { useAgentState } from "#/hooks/use-agent-state";
|
||||||
|
|
||||||
function VSCodeTab() {
|
function VSCodeTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, error } = useVSCodeUrl();
|
const { data, isLoading, error } = useUnifiedVSCodeUrl();
|
||||||
const { curAgentState } = useAgentState();
|
const { curAgentState } = useAgentState();
|
||||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
@@ -39,10 +39,18 @@ function VSCodeTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRuntimeInactive || isLoading) {
|
if (isRuntimeInactive) {
|
||||||
return <WaitingForRuntimeMessage />;
|
return <WaitingForRuntimeMessage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||||
|
{t(I18nKey.VSCODE$LOADING)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error || (data && data.error) || !data?.url || iframeError) {
|
if (error || (data && data.error) || !data?.url || iframeError) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||||
|
|||||||
Reference in New Issue
Block a user