diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index bcb183a106a7..caef81a9a1a1 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -10,6 +10,7 @@ import { AuthenticateResponse, Conversation, ResultSet, + GetTrajectoryResponse, } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; import { ApiSettings } from "#/services/settings"; @@ -354,6 +355,15 @@ class OpenHands { return response.data.items; } + + static async getTrajectory( + conversationId: string, + ): Promise { + const { data } = await openHands.get( + `/api/conversations/${conversationId}/trajectory`, + ); + return data; + } } export default OpenHands; diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 169de47afb39..995c2f5f7203 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -55,6 +55,11 @@ export interface GetVSCodeUrlResponse { error?: string; } +export interface GetTrajectoryResponse { + trajectory: unknown[] | null; + error?: string; +} + export interface AuthenticateResponse { message?: string; error?: string; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index eb934b69b9ed..e04efa19b3cc 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -1,8 +1,11 @@ import { useDispatch, useSelector } from "react-redux"; +import toast from "react-hot-toast"; import React from "react"; import posthog from "posthog-js"; +import { useParams } from "react-router"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { FeedbackActions } from "../feedback/feedback-actions"; +import { ExportActions } from "../export/export-actions"; import { createChatMessage } from "#/services/chat-service"; import { InteractiveChatBox } from "./interactive-chat-box"; import { addUserMessage } from "#/state/chat-slice"; @@ -19,6 +22,8 @@ import { ActionSuggestions } from "./action-suggestions"; import { ContinueButton } from "#/components/shared/buttons/continue-button"; import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory"; +import { downloadTrajectory } from "#/utils/download-files"; function getEntryPoint( hasRepository: boolean | null, @@ -47,6 +52,8 @@ export function ChatInterface() { const { selectedRepository, importedProjectZip } = useSelector( (state: RootState) => state.initialQuery, ); + const params = useParams(); + const { mutate: getTrajectory } = useGetTrajectory(); const handleSendMessage = async (content: string, files: File[]) => { if (messages.length === 0) { @@ -90,6 +97,25 @@ export function ChatInterface() { setFeedbackPolarity(polarity); }; + const onClickExportTrajectoryButton = () => { + if (!params.conversationId) { + toast.error("ConversationId unknown, cannot download trajectory"); + return; + } + + getTrajectory(params.conversationId, { + onSuccess: async (data) => { + await downloadTrajectory( + params.conversationId ?? "unknown", + data.trajectory, + ); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + }; + const isWaitingForUserInput = curAgentState === AgentState.AWAITING_USER_INPUT || curAgentState === AgentState.FINISHED; @@ -137,6 +163,9 @@ export function ChatInterface() { onClickShareFeedbackActionButton("negative") } /> + onClickExportTrajectoryButton()} + />
{messages.length > 2 && diff --git a/frontend/src/components/features/export/export-actions.tsx b/frontend/src/components/features/export/export-actions.tsx new file mode 100644 index 000000000000..faafd98af293 --- /dev/null +++ b/frontend/src/components/features/export/export-actions.tsx @@ -0,0 +1,17 @@ +import ExportIcon from "#/icons/export.svg?react"; +import { ExportActionButton } from "#/components/shared/buttons/export-action-button"; + +interface ExportActionsProps { + onExportTrajectory: () => void; +} + +export function ExportActions({ onExportTrajectory }: ExportActionsProps) { + return ( +
+ } + /> +
+ ); +} diff --git a/frontend/src/components/shared/buttons/export-action-button.tsx b/frontend/src/components/shared/buttons/export-action-button.tsx new file mode 100644 index 000000000000..b76443591d39 --- /dev/null +++ b/frontend/src/components/shared/buttons/export-action-button.tsx @@ -0,0 +1,17 @@ +interface ExportActionButtonProps { + onClick: () => void; + icon: React.ReactNode; +} + +export function ExportActionButton({ onClick, icon }: ExportActionButtonProps) { + return ( + + ); +} diff --git a/frontend/src/hooks/mutation/use-get-trajectory.ts b/frontend/src/hooks/mutation/use-get-trajectory.ts new file mode 100644 index 000000000000..e2ad96e64d24 --- /dev/null +++ b/frontend/src/hooks/mutation/use-get-trajectory.ts @@ -0,0 +1,7 @@ +import { useMutation } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; + +export const useGetTrajectory = () => + useMutation({ + mutationFn: (cid: string) => OpenHands.getTrajectory(cid), + }); diff --git a/frontend/src/icons/export.svg b/frontend/src/icons/export.svg new file mode 100644 index 000000000000..d9d52ecb48ac --- /dev/null +++ b/frontend/src/icons/export.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/types/file-system.d.ts b/frontend/src/types/file-system.d.ts index c2d823f701e0..90caf2ba3809 100644 --- a/frontend/src/types/file-system.d.ts +++ b/frontend/src/types/file-system.d.ts @@ -26,6 +26,18 @@ interface FileSystemDirectoryHandle { ): Promise; } +interface SaveFilePickerOptions { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; + excludeAcceptAllOption?: boolean; +} + interface Window { showDirectoryPicker(): Promise; + showSaveFilePicker( + options?: SaveFilePickerOptions, + ): Promise; } diff --git a/frontend/src/utils/download-files.ts b/frontend/src/utils/download-files.ts index 1bcd5eb0fd8f..5e17c2b5c317 100644 --- a/frontend/src/utils/download-files.ts +++ b/frontend/src/utils/download-files.ts @@ -22,6 +22,13 @@ function isFileSystemAccessSupported(): boolean { return "showDirectoryPicker" in window; } +/** + * Checks if the Save File Picker API is supported + */ +function isSaveFilePickerSupported(): boolean { + return "showSaveFilePicker" in window; +} + /** * Creates subdirectories and returns the final directory handle */ @@ -162,6 +169,39 @@ async function processBatch( }; } +export async function downloadTrajectory( + conversationId: string, + data: unknown[] | null, +): Promise { + try { + if (!isSaveFilePickerSupported()) { + throw new Error( + "Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.", + ); + } + const options = { + suggestedName: `trajectory-${conversationId}.json`, + types: [ + { + description: "JSON File", + accept: { + "application/json": [".json"], + }, + }, + ], + }; + + const handle = await window.showSaveFilePicker(options); + const writable = await handle.createWritable(); + await writable.write(JSON.stringify(data, null, 2)); + await writable.close(); + } catch (error) { + throw new Error( + `Failed to download file: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + /** * Downloads files from the workspace one by one * @param initialPath Initial path to start downloading from. If not provided, downloads from root diff --git a/openhands/server/app.py b/openhands/server/app.py index ea95bcf3e0b4..4e1a094317da 100644 --- a/openhands/server/app.py +++ b/openhands/server/app.py @@ -27,6 +27,7 @@ from openhands.server.routes.public import app as public_api_router from openhands.server.routes.security import app as security_api_router from openhands.server.routes.settings import app as settings_router +from openhands.server.routes.trajectory import app as trajectory_router from openhands.server.shared import openhands_config, session_manager from openhands.utils.import_utils import get_impl @@ -69,6 +70,7 @@ async def health(): app.include_router(manage_conversation_api_router) app.include_router(settings_router) app.include_router(github_api_router) +app.include_router(trajectory_router) AttachConversationMiddlewareImpl = get_impl( AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path diff --git a/openhands/server/routes/trajectory.py b/openhands/server/routes/trajectory.py new file mode 100644 index 000000000000..5b2e0097f284 --- /dev/null +++ b/openhands/server/routes/trajectory.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from openhands.core.logger import openhands_logger as logger +from openhands.events.serialization import event_to_trajectory +from openhands.events.stream import AsyncEventStreamWrapper + +app = APIRouter(prefix='/api/conversations/{conversation_id}') + + +@app.get('/trajectory') +async def get_trajectory(request: Request): + """Get trajectory. + + This function retrieves the current trajectory and returns it. + + Args: + request (Request): The incoming request object. + + Returns: + JSONResponse: A JSON response containing the trajectory as a list of + events. + """ + try: + async_stream = AsyncEventStreamWrapper( + request.state.conversation.event_stream, filter_hidden=True + ) + trajectory = [] + async for event in async_stream: + trajectory.append(event_to_trajectory(event)) + return JSONResponse(status_code=200, content={'trajectory': trajectory}) + except Exception as e: + logger.error(f'Error getting trajectory: {e}', exc_info=True) + return JSONResponse( + status_code=500, + content={ + 'trajectory': None, + 'error': f'Error getting trajectory: {e}', + }, + )