mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import axios from "axios";
|
|
import { openHands } from "../open-hands-axios";
|
|
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
|
import { Provider } from "#/types/settings";
|
|
import { SuggestedTask } from "#/utils/types";
|
|
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
|
import { buildSessionHeaders } from "#/utils/utils";
|
|
import type {
|
|
V1SendMessageRequest,
|
|
V1SendMessageResponse,
|
|
V1AppConversationStartRequest,
|
|
V1AppConversationStartTask,
|
|
V1AppConversationStartTaskPage,
|
|
V1AppConversation,
|
|
GetSkillsResponse,
|
|
V1RuntimeConversationInfo,
|
|
} from "./v1-conversation-service.types";
|
|
|
|
class V1ConversationService {
|
|
/**
|
|
* Build the full URL for V1 runtime-specific endpoints
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param path The API path (e.g., "/api/vscode/url")
|
|
* @returns Full URL to the runtime endpoint
|
|
*/
|
|
private static buildRuntimeUrl(
|
|
conversationUrl: string | null | undefined,
|
|
path: string,
|
|
): string {
|
|
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
|
return `${baseUrl}${path}`;
|
|
}
|
|
|
|
/**
|
|
* Send a message to a V1 conversation
|
|
* @param conversationId The conversation ID
|
|
* @param message The message to send
|
|
* @returns The sent message response
|
|
*/
|
|
static async sendMessage(
|
|
conversationId: string,
|
|
message: V1SendMessageRequest,
|
|
): Promise<V1SendMessageResponse> {
|
|
const { data } = await openHands.post<V1SendMessageResponse>(
|
|
`/api/conversations/${conversationId}/events`,
|
|
message,
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Create a new V1 conversation using the app-conversations API
|
|
* Returns the start task immediately with app_conversation_id as null.
|
|
* You must poll getStartTask() until status is READY to get the conversation ID.
|
|
*
|
|
* @returns AppConversationStartTask with task ID
|
|
*/
|
|
static async createConversation(
|
|
selectedRepository?: string,
|
|
git_provider?: Provider,
|
|
initialUserMsg?: string,
|
|
selected_branch?: string,
|
|
conversationInstructions?: string,
|
|
suggestedTask?: SuggestedTask,
|
|
trigger?: ConversationTrigger,
|
|
parent_conversation_id?: string,
|
|
agent_type?: "default" | "plan",
|
|
): Promise<V1AppConversationStartTask> {
|
|
const body: V1AppConversationStartRequest = {
|
|
selected_repository: selectedRepository,
|
|
git_provider,
|
|
selected_branch,
|
|
suggested_task: suggestedTask,
|
|
title: conversationInstructions,
|
|
trigger,
|
|
parent_conversation_id: parent_conversation_id || null,
|
|
agent_type,
|
|
};
|
|
|
|
// suggested_task implies the backend will construct the initial_message
|
|
if (!suggestedTask && initialUserMsg) {
|
|
body.initial_message = {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: initialUserMsg,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const { data } = await openHands.post<V1AppConversationStartTask>(
|
|
"/api/v1/app-conversations",
|
|
body,
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Get a start task by ID
|
|
* Poll this endpoint until status is READY to get the app_conversation_id
|
|
*
|
|
* @param taskId The task UUID
|
|
* @returns AppConversationStartTask or null
|
|
*/
|
|
static async getStartTask(
|
|
taskId: string,
|
|
): Promise<V1AppConversationStartTask | null> {
|
|
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
|
|
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
|
|
);
|
|
|
|
return data[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Search for start tasks (ongoing tasks that haven't completed yet)
|
|
* Use this to find tasks that were started but the user navigated away
|
|
*
|
|
* Note: Backend supports filtering by limit and created_at__gte. To filter by repository/trigger,
|
|
* filter the results client-side after fetching.
|
|
*
|
|
* @param limit Maximum number of tasks to return (max 100)
|
|
* @returns Array of start tasks from the last 20 minutes
|
|
*/
|
|
static async searchStartTasks(
|
|
limit: number = 100,
|
|
): Promise<V1AppConversationStartTask[]> {
|
|
const params = new URLSearchParams();
|
|
params.append("limit", limit.toString());
|
|
|
|
// Only get tasks from the last 20 minutes
|
|
const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000);
|
|
params.append("created_at__gte", twentyMinutesAgo.toISOString());
|
|
|
|
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
|
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
|
);
|
|
|
|
return data.items;
|
|
}
|
|
|
|
/**
|
|
* Get the VSCode URL for a V1 conversation
|
|
* Uses the custom runtime URL from the conversation
|
|
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
|
|
*
|
|
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param sessionApiKey Session API key for authentication (required for V1)
|
|
* @returns VSCode URL response
|
|
*/
|
|
static async getVSCodeUrl(
|
|
_conversationId: string,
|
|
conversationUrl: string | null | undefined,
|
|
sessionApiKey?: string | null,
|
|
): Promise<GetVSCodeUrlResponse> {
|
|
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
|
const headers = buildSessionHeaders(sessionApiKey);
|
|
|
|
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
|
// Map it to match the expected interface
|
|
const { data } = await axios.get<{ url: string | null }>(url, { headers });
|
|
return {
|
|
vscode_url: data.url,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pause a V1 conversation
|
|
* Uses the custom runtime URL from the conversation
|
|
*
|
|
* @param conversationId The conversation ID
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param sessionApiKey Session API key for authentication (required for V1)
|
|
* @returns Success response
|
|
*/
|
|
static async pauseConversation(
|
|
conversationId: string,
|
|
conversationUrl: string | null | undefined,
|
|
sessionApiKey?: string | null,
|
|
): Promise<{ success: boolean }> {
|
|
const url = this.buildRuntimeUrl(
|
|
conversationUrl,
|
|
`/api/conversations/${conversationId}/pause`,
|
|
);
|
|
const headers = buildSessionHeaders(sessionApiKey);
|
|
|
|
const { data } = await axios.post<{ success: boolean }>(
|
|
url,
|
|
{},
|
|
{ headers },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Resume a V1 conversation
|
|
* Uses the custom runtime URL from the conversation
|
|
*
|
|
* @param conversationId The conversation ID
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param sessionApiKey Session API key for authentication (required for V1)
|
|
* @returns Success response
|
|
*/
|
|
static async resumeConversation(
|
|
conversationId: string,
|
|
conversationUrl: string | null | undefined,
|
|
sessionApiKey?: string | null,
|
|
): Promise<{ success: boolean }> {
|
|
const url = this.buildRuntimeUrl(
|
|
conversationUrl,
|
|
`/api/conversations/${conversationId}/run`,
|
|
);
|
|
const headers = buildSessionHeaders(sessionApiKey);
|
|
|
|
const { data } = await axios.post<{ success: boolean }>(
|
|
url,
|
|
{},
|
|
{ headers },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Batch get V1 app conversations by their IDs
|
|
* Returns null for any missing conversations
|
|
*
|
|
* @param ids Array of conversation IDs (max 100)
|
|
* @returns Array of conversations or null for missing ones
|
|
*/
|
|
static async batchGetAppConversations(
|
|
ids: string[],
|
|
): Promise<(V1AppConversation | null)[]> {
|
|
if (ids.length === 0) {
|
|
return [];
|
|
}
|
|
if (ids.length > 100) {
|
|
throw new Error("Cannot request more than 100 conversations at once");
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
ids.forEach((id) => params.append("ids", id));
|
|
|
|
const { data } = await openHands.get<(V1AppConversation | null)[]>(
|
|
`/api/v1/app-conversations?${params.toString()}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Upload a single file to the V1 conversation workspace
|
|
* V1 API endpoint: POST /api/file/upload?path={path}
|
|
*
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param sessionApiKey Session API key for authentication (required for V1)
|
|
* @param file The file to upload
|
|
* @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name})
|
|
* @returns void on success, throws on error
|
|
*/
|
|
static async uploadFile(
|
|
conversationUrl: string | null | undefined,
|
|
sessionApiKey: string | null | undefined,
|
|
file: File,
|
|
path?: string,
|
|
): Promise<void> {
|
|
// Default to /workspace/{filename} if no path provided (must be absolute)
|
|
const uploadPath = path || `/workspace/${file.name}`;
|
|
const params = new URLSearchParams();
|
|
params.append("path", uploadPath);
|
|
const url = this.buildRuntimeUrl(
|
|
conversationUrl,
|
|
`/api/file/upload?${params.toString()}`,
|
|
);
|
|
const headers = buildSessionHeaders(sessionApiKey);
|
|
|
|
// Create FormData with the file
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
// Upload file
|
|
await axios.post(url, formData, {
|
|
headers: {
|
|
...headers,
|
|
"Content-Type": "multipart/form-data",
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the conversation config (runtime_id) for a V1 conversation
|
|
* @param conversationId The conversation ID
|
|
* @returns Object containing runtime_id
|
|
*/
|
|
static async getConversationConfig(
|
|
conversationId: string,
|
|
): Promise<{ runtime_id: string }> {
|
|
const url = `/api/conversations/${conversationId}/config`;
|
|
const { data } = await openHands.get<{ runtime_id: string }>(url);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Update a V1 conversation's public flag
|
|
* @param conversationId The conversation ID
|
|
* @param isPublic Whether the conversation should be public
|
|
* @returns Updated conversation info
|
|
*/
|
|
static async updateConversationPublicFlag(
|
|
conversationId: string,
|
|
isPublic: boolean,
|
|
): Promise<V1AppConversation> {
|
|
const { data } = await openHands.patch<V1AppConversation>(
|
|
`/api/v1/app-conversations/${conversationId}`,
|
|
{ public: isPublic },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Update a V1 conversation's repository settings
|
|
* @param conversationId The conversation ID
|
|
* @param repository The repository to attach (e.g., "owner/repo") or null to remove
|
|
* @param branch The branch to use (optional)
|
|
* @param gitProvider The git provider (e.g., "github", "gitlab")
|
|
* @returns Updated conversation info
|
|
*/
|
|
static async updateConversationRepository(
|
|
conversationId: string,
|
|
repository: string | null,
|
|
branch?: string | null,
|
|
gitProvider?: string | null,
|
|
): Promise<V1AppConversation> {
|
|
const payload: Record<string, string | null | undefined> = {};
|
|
|
|
if (repository !== undefined) {
|
|
payload.selected_repository = repository;
|
|
}
|
|
if (branch !== undefined) {
|
|
payload.selected_branch = branch;
|
|
}
|
|
if (gitProvider !== undefined) {
|
|
payload.git_provider = gitProvider;
|
|
}
|
|
|
|
const { data } = await openHands.patch<V1AppConversation>(
|
|
`/api/v1/app-conversations/${conversationId}`,
|
|
payload,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Read a file from a specific conversation's sandbox workspace
|
|
* @param conversationId The conversation ID
|
|
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/.agents_tmp/PLAN.md)
|
|
* @returns The content of the file or an empty string if the file doesn't exist
|
|
*/
|
|
static async readConversationFile(
|
|
conversationId: string,
|
|
filePath: string = "/workspace/project/.agents_tmp/PLAN.md",
|
|
): Promise<string> {
|
|
const params = new URLSearchParams();
|
|
params.append("file_path", filePath);
|
|
|
|
const { data } = await openHands.get<string>(
|
|
`/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Download a conversation trajectory as a zip file
|
|
* @param conversationId The conversation ID
|
|
* @returns A blob containing the zip file
|
|
*/
|
|
static async downloadConversation(conversationId: string): Promise<Blob> {
|
|
const response = await openHands.get(
|
|
`/api/v1/app-conversations/${conversationId}/download`,
|
|
{
|
|
responseType: "blob",
|
|
},
|
|
);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Get all skills associated with a V1 conversation
|
|
* @param conversationId The conversation ID
|
|
* @returns The available skills associated with the conversation
|
|
*/
|
|
static async getSkills(conversationId: string): Promise<GetSkillsResponse> {
|
|
const { data } = await openHands.get<GetSkillsResponse>(
|
|
`/api/v1/app-conversations/${conversationId}/skills`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Get conversation info directly from the runtime for a V1 conversation
|
|
* Uses the custom runtime URL from the conversation
|
|
*
|
|
* @param conversationId The conversation ID
|
|
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
|
* @param sessionApiKey Session API key for authentication (required for V1)
|
|
* @returns Conversation info from the runtime
|
|
*/
|
|
static async getRuntimeConversation(
|
|
conversationId: string,
|
|
conversationUrl: string | null | undefined,
|
|
sessionApiKey?: string | null,
|
|
): Promise<V1RuntimeConversationInfo> {
|
|
const url = this.buildRuntimeUrl(
|
|
conversationUrl,
|
|
`/api/conversations/${conversationId}`,
|
|
);
|
|
const headers = buildSessionHeaders(sessionApiKey);
|
|
|
|
const { data } = await axios.get<V1RuntimeConversationInfo>(url, {
|
|
headers,
|
|
});
|
|
return data;
|
|
}
|
|
}
|
|
|
|
export default V1ConversationService;
|