mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(frontend): V1 confirmation's call the right API (#11542)
This commit is contained in:
@@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
@@ -13,21 +14,6 @@ import type {
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@@ -160,7 +146,7 @@ class V1ConversationService {
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
@@ -188,7 +174,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@@ -216,7 +202,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/run`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@@ -305,7 +291,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Create FormData with the file
|
||||
const formData = new FormData();
|
||||
|
||||
41
frontend/src/api/event-service/event-service.api.ts
Normal file
41
frontend/src/api/event-service/event-service.api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
* Respond to a confirmation request in a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param request The confirmation response request
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns The confirmation response
|
||||
*/
|
||||
static async respondToConfirmation(
|
||||
conversationId: string,
|
||||
conversationUrl: string,
|
||||
request: ConfirmationResponseRequest,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<ConfirmationResponseResponse> {
|
||||
// Build the runtime URL using the conversation URL
|
||||
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
|
||||
|
||||
// Build session headers for authentication
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Make the API call to the runtime endpoint
|
||||
const { data } = await axios.post<ConfirmationResponseResponse>(
|
||||
`${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
request,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
8
frontend/src/api/event-service/event-service.types.ts
Normal file
8
frontend/src/api/event-service/event-service.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ConfirmationResponseRequest {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmationResponseResponse {
|
||||
success: boolean;
|
||||
}
|
||||
@@ -237,14 +237,7 @@ export function ChatInterface() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{v1UserEventsExist && <V1Messages messages={v1Events} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { RiskAlert } from "#/components/shared/risk-alert";
|
||||
import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV1Event, isActionEvent } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
|
||||
export function V1ConfirmationButtons() {
|
||||
const v1SubmittedEventIds = useEventMessageStore(
|
||||
(state) => state.v1SubmittedEventIds,
|
||||
);
|
||||
const addV1SubmittedEventId = useEventMessageStore(
|
||||
(state) => state.addV1SubmittedEventId,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { mutate: respondToConfirmation } = useRespondToConfirmation();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent V1 action awaiting confirmation
|
||||
const awaitingAction = events
|
||||
.filter(isV1Event)
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((ev) => {
|
||||
if (ev.source !== "agent") return false;
|
||||
// For V1, we check if the agent state is waiting for confirmation
|
||||
return curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
});
|
||||
|
||||
const handleConfirmation = useCallback(
|
||||
(accept: boolean) => {
|
||||
if (!awaitingAction || !conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as submitted to prevent duplicate submissions
|
||||
addV1SubmittedEventId(awaitingAction.id);
|
||||
|
||||
// Call the V1 API endpoint
|
||||
respondToConfirmation({
|
||||
conversationId: conversation.conversation_id,
|
||||
conversationUrl: conversation.url || "",
|
||||
sessionApiKey: conversation.session_api_key,
|
||||
accept,
|
||||
});
|
||||
},
|
||||
[
|
||||
awaitingAction,
|
||||
conversation,
|
||||
addV1SubmittedEventId,
|
||||
respondToConfirmation,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!awaitingAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleCancelShortcut = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueShortcut = (event: KeyboardEvent) => {
|
||||
if (event.metaKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
|
||||
handleCancelShortcut(event);
|
||||
// Continue: Cmd+Enter (⌘↩)
|
||||
handleContinueShortcut(event);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [awaitingAction, handleConfirmation]);
|
||||
|
||||
// Only show if agent is waiting for confirmation and we haven't already submitted
|
||||
if (
|
||||
curAgentState !== AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
!awaitingAction ||
|
||||
v1SubmittedEventIds.includes(awaitingAction.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get security risk from the action (only ActionEvent has security_risk)
|
||||
const risk = isActionEvent(awaitingAction)
|
||||
? awaitingAction.security_risk
|
||||
: SecurityRisk.UNKNOWN;
|
||||
|
||||
const isHighRisk = risk === SecurityRisk.HIGH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{isHighRisk && (
|
||||
<RiskAlert
|
||||
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
|
||||
icon={<WarningIcon width={16} height={16} color="#fff" />}
|
||||
severity="high"
|
||||
title={t(I18nKey.COMMON$HIGH_RISK)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm font-normal text-white">
|
||||
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionTooltip
|
||||
type="reject"
|
||||
onClick={() => handleConfirmation(false)}
|
||||
/>
|
||||
<ActionTooltip
|
||||
type="confirm"
|
||||
onClick={() => handleConfirmation(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
isLastMessage,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
@@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
@@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
@@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps {
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function UserAssistantEventMessage({
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@@ -38,7 +37,6 @@ interface EventMessageProps {
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@@ -46,9 +44,6 @@ export function EventMessage({
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
@@ -103,17 +98,14 @@ export function EventMessage({
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,10 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
({ messages }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
@@ -43,7 +42,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types";
|
||||
|
||||
interface UseRespondToConfirmationVariables {
|
||||
conversationId: string;
|
||||
conversationUrl: string;
|
||||
sessionApiKey?: string | null;
|
||||
accept: boolean;
|
||||
}
|
||||
|
||||
export const useRespondToConfirmation = () =>
|
||||
useMutation({
|
||||
mutationKey: ["respond-to-confirmation"],
|
||||
mutationFn: async ({
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
accept,
|
||||
}: UseRespondToConfirmationVariables) => {
|
||||
const request: ConfirmationResponseRequest = {
|
||||
accept,
|
||||
};
|
||||
|
||||
return EventService.respondToConfirmation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
request,
|
||||
sessionApiKey,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -2,15 +2,19 @@ import { create } from "zustand";
|
||||
|
||||
interface EventMessageState {
|
||||
submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons
|
||||
v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs)
|
||||
}
|
||||
|
||||
interface EventMessageStore extends EventMessageState {
|
||||
addSubmittedEventId: (id: number) => void;
|
||||
removeSubmittedEventId: (id: number) => void;
|
||||
addV1SubmittedEventId: (id: string) => void;
|
||||
removeV1SubmittedEventId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
submittedEventIds: [],
|
||||
v1SubmittedEventIds: [],
|
||||
addSubmittedEventId: (id: number) =>
|
||||
set((state) => ({
|
||||
submittedEventIds: [...state.submittedEventIds, id],
|
||||
@@ -21,4 +25,14 @@ export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
addV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: [...state.v1SubmittedEventIds, id],
|
||||
})),
|
||||
removeV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: state.v1SubmittedEventIds.filter(
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -594,3 +594,18 @@ export const hasOpenHandsSuffix = (
|
||||
}
|
||||
return repo.full_name.endsWith("/.openhands");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
export const buildSessionHeaders = (
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user