mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
4 Commits
debug/lite
...
v1-convers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71325f50d | ||
|
|
c79652142b | ||
|
|
aa15020749 | ||
|
|
74c337f200 |
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
} from "../open-hands.types";
|
||||
|
||||
class AppConversationServiceCallback {
|
||||
/**
|
||||
* Start an app conversation with streaming updates using callback pattern
|
||||
* This approach avoids the no-await-in-loop ESLint warning
|
||||
* @param request The conversation start request
|
||||
* @param onProgress Callback function called for each progress update
|
||||
* @param onComplete Callback function called when streaming is complete
|
||||
* @param onError Callback function called when an error occurs
|
||||
* @returns Promise that resolves when the stream starts (not when it completes)
|
||||
*/
|
||||
static async streamStartAppConversation(
|
||||
request: AppConversationStartRequest,
|
||||
onProgress: (task: AppConversationStartTask) => void,
|
||||
onComplete: (allTasks: AppConversationStartTask[]) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<void> {
|
||||
const baseURL = `${window.location.protocol}//${
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
|
||||
}`;
|
||||
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const allTasks: AppConversationStartTask[] = [];
|
||||
|
||||
const processStream = async (): Promise<void> => {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
const trimmedBuffer = buffer.trim();
|
||||
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
|
||||
const cleanBuffer = trimmedBuffer.replace(/,$/, "");
|
||||
if (cleanBuffer) {
|
||||
try {
|
||||
const task: AppConversationStartTask =
|
||||
JSON.parse(cleanBuffer);
|
||||
allTasks.push(task);
|
||||
onProgress(task);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to parse final JSON:",
|
||||
cleanBuffer,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(allTasks);
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// The API returns a JSON array that gets built incrementally
|
||||
// We need to parse individual JSON objects as they come in
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
|
||||
// Remove trailing comma if present
|
||||
const cleanLine = trimmedLine.replace(/,$/, "");
|
||||
if (cleanLine) {
|
||||
try {
|
||||
const task: AppConversationStartTask = JSON.parse(cleanLine);
|
||||
allTasks.push(task);
|
||||
onProgress(task);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse JSON line:", cleanLine, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue processing the stream
|
||||
processStream();
|
||||
} catch (error) {
|
||||
reader.releaseLock();
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
// Start processing the stream
|
||||
processStream();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AppConversationServiceCallback;
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
} from "../open-hands.types";
|
||||
|
||||
class AppConversationService {
|
||||
/**
|
||||
* Start an app conversation with streaming updates
|
||||
* @param request The conversation start request
|
||||
* @returns AsyncGenerator that yields AppConversationStartTask updates
|
||||
*/
|
||||
static async *streamStartAppConversation(
|
||||
request: AppConversationStartRequest,
|
||||
): AsyncGenerator<AppConversationStartTask, void, unknown> {
|
||||
const baseURL = `${window.location.protocol}//${
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
|
||||
}`;
|
||||
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential reading from stream required
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// The API returns a JSON array that gets built incrementally
|
||||
// We need to parse individual JSON objects as they come in
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
|
||||
// Remove trailing comma if present
|
||||
const cleanLine = trimmedLine.replace(/,$/, "");
|
||||
if (cleanLine) {
|
||||
try {
|
||||
const task: AppConversationStartTask = JSON.parse(cleanLine);
|
||||
yield task;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse JSON line:", cleanLine, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
const trimmedBuffer = buffer.trim();
|
||||
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
|
||||
const cleanBuffer = trimmedBuffer
|
||||
.replace(/,?\]$/, "")
|
||||
.replace(/,$/, "");
|
||||
if (cleanBuffer) {
|
||||
try {
|
||||
const task: AppConversationStartTask = JSON.parse(cleanBuffer);
|
||||
yield task;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse final JSON:", cleanBuffer, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AppConversationService;
|
||||
@@ -76,6 +76,7 @@ export interface Conversation {
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version: "V0" | "V1";
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
@@ -139,3 +140,50 @@ export type GetFilesResponse = string[];
|
||||
export interface GetFileResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// App Conversation Types
|
||||
export interface SendMessageRequest {
|
||||
message: string;
|
||||
image_urls?: string[];
|
||||
}
|
||||
|
||||
export interface EventCallbackProcessor {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AppConversationStartRequest {
|
||||
sandbox_id?: string | null;
|
||||
initial_message?: SendMessageRequest | null;
|
||||
processors?: EventCallbackProcessor[];
|
||||
llm_model?: string | null;
|
||||
selected_repository?: string | null;
|
||||
selected_branch?: string | null;
|
||||
git_provider?: Provider | null;
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
}
|
||||
|
||||
export type AppConversationStartTaskStatus =
|
||||
| "WORKING"
|
||||
| "WAITING_FOR_SANDBOX"
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
| "STARTING_CONVERSATION"
|
||||
| "READY"
|
||||
| "ERROR";
|
||||
|
||||
export interface AppConversationStartTask {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
status: AppConversationStartTaskStatus;
|
||||
detail?: string | null;
|
||||
app_conversation_id?: string | null;
|
||||
sandbox_id?: string | null;
|
||||
agent_server_url?: string | null;
|
||||
request: AppConversationStartRequest;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
@@ -36,6 +36,9 @@ import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
import { useStreamStartAppConversation } from "#/hooks/mutation/use-stream-start-app-conversation";
|
||||
import { AppConversationStartRequest } from "#/api/open-hands.types";
|
||||
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -54,6 +57,7 @@ export function ChatInterface() {
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
scrollDomToBottom,
|
||||
@@ -65,7 +69,55 @@ export function ChatInterface() {
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState, setCurrentAgentState } = useAgentStore();
|
||||
|
||||
// Get setup state from store
|
||||
const {
|
||||
isSetupMode,
|
||||
conversationId: setupConversationId,
|
||||
setCurrentTask,
|
||||
setIsSetupMode,
|
||||
} = useConversationSetupStore();
|
||||
const { mutate: startConversation } = useStreamStartAppConversation();
|
||||
|
||||
// Start conversation setup when in setup mode
|
||||
React.useEffect(() => {
|
||||
if (isSetupMode && setupConversationId) {
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
|
||||
const request: AppConversationStartRequest = {
|
||||
initial_message: {
|
||||
message: "Hello! I'm ready to help you with your project.",
|
||||
image_urls: [],
|
||||
},
|
||||
};
|
||||
|
||||
startConversation({
|
||||
request,
|
||||
onProgress: (task) => {
|
||||
setCurrentTask(task);
|
||||
|
||||
// When ready, navigate to the actual conversation and exit setup mode
|
||||
if (task.status === "READY" && task.app_conversation_id) {
|
||||
setCurrentAgentState(AgentState.INIT);
|
||||
setIsSetupMode(false);
|
||||
// Replace the URL to remove setup parameter
|
||||
navigate(`/conversations/${task.app_conversation_id}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isSetupMode,
|
||||
setupConversationId,
|
||||
startConversation,
|
||||
setCurrentAgentState,
|
||||
setCurrentTask,
|
||||
setIsSetupMode,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { SetupStatusIndicator } from "./setup-status-indicator";
|
||||
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
@@ -18,10 +20,14 @@ export function ChatInputActions({
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
// Get setup state from store
|
||||
const { isSetupMode, currentTask } = useConversationSetupStore();
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<SetupStatusIndicator task={currentTask} isActive={isSetupMode} />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { AppConversationStartTask } from "#/api/open-hands.types";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
|
||||
interface SetupStatusIndicatorProps {
|
||||
task: AppConversationStartTask | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function SetupStatusIndicator({
|
||||
task,
|
||||
isActive,
|
||||
}: SetupStatusIndicatorProps) {
|
||||
if (!isActive || !task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusMessage = (status: string) => {
|
||||
const messages: Record<string, string> = {
|
||||
WORKING: "Initializing...",
|
||||
WAITING_FOR_SANDBOX: "Setting up environment...",
|
||||
PREPARING_REPOSITORY: "Preparing repository...",
|
||||
RUNNING_SETUP_SCRIPT: "Running setup...",
|
||||
SETTING_UP_GIT_HOOKS: "Configuring git...",
|
||||
STARTING_CONVERSATION: "Starting conversation...",
|
||||
READY: "Ready",
|
||||
ERROR: "Setup failed",
|
||||
};
|
||||
return messages[status] || status;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status === "ERROR") {
|
||||
return "#FF684E"; // Red
|
||||
}
|
||||
if (status === "READY") {
|
||||
return "#BCFF8C"; // Green
|
||||
}
|
||||
return "#FFD600"; // Yellow for in-progress
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor(task.status);
|
||||
const statusText = getStatusMessage(task.status);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
|
||||
<span className="text-[11px] text-white font-normal leading-5">
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useStreamStartAppConversation } from "#/hooks/mutation/use-stream-start-app-conversation";
|
||||
import {
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
} from "#/api/open-hands.types";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
|
||||
// Component that shows in the chat input area during setup
|
||||
function ConversationSetupInput({
|
||||
task,
|
||||
}: {
|
||||
task: AppConversationStartTask | null;
|
||||
}) {
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2">Initializing conversation...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusMessage = (status: string) => {
|
||||
const messages = {
|
||||
WORKING: "Starting your conversation...",
|
||||
WAITING_FOR_SANDBOX: "Setting up secure environment...",
|
||||
PREPARING_REPOSITORY: "Preparing repository...",
|
||||
RUNNING_SETUP_SCRIPT: "Running setup scripts...",
|
||||
SETTING_UP_GIT_HOOKS: "Configuring git integration...",
|
||||
STARTING_CONVERSATION: "Almost ready...",
|
||||
READY: "Conversation ready!",
|
||||
ERROR: "Setup failed",
|
||||
};
|
||||
return messages[status] || status;
|
||||
};
|
||||
|
||||
const getProgress = (status: string) => {
|
||||
const progress = {
|
||||
WORKING: 10,
|
||||
WAITING_FOR_SANDBOX: 25,
|
||||
PREPARING_REPOSITORY: 50,
|
||||
RUNNING_SETUP_SCRIPT: 70,
|
||||
SETTING_UP_GIT_HOOKS: 85,
|
||||
STARTING_CONVERSATION: 95,
|
||||
READY: 100,
|
||||
ERROR: 0,
|
||||
};
|
||||
return progress[status] || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${getProgress(task.status)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{getStatusMessage(task.status)}
|
||||
</span>
|
||||
{task.status !== "ERROR" && task.status !== "READY" && (
|
||||
<LoadingSpinner size="small" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail message */}
|
||||
{task.detail && <p className="text-xs text-gray-500">{task.detail}</p>}
|
||||
|
||||
{/* Error state */}
|
||||
{task.status === "ERROR" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-red-600 text-sm">
|
||||
Setup failed. Please try again.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-blue-500 hover:underline text-sm"
|
||||
>
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component that shows setup steps in the main chat area
|
||||
function ConversationSetupProgress({
|
||||
task,
|
||||
error,
|
||||
}: {
|
||||
task: AppConversationStartTask | null;
|
||||
error: Error | null;
|
||||
}) {
|
||||
const setupSteps = [
|
||||
{ key: "WORKING", label: "Initializing", completed: false },
|
||||
{
|
||||
key: "WAITING_FOR_SANDBOX",
|
||||
label: "Setting up environment",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
key: "PREPARING_REPOSITORY",
|
||||
label: "Preparing repository",
|
||||
completed: false,
|
||||
},
|
||||
{ key: "RUNNING_SETUP_SCRIPT", label: "Running setup", completed: false },
|
||||
{ key: "SETTING_UP_GIT_HOOKS", label: "Configuring git", completed: false },
|
||||
{
|
||||
key: "STARTING_CONVERSATION",
|
||||
label: "Starting conversation",
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Mark steps as completed based on current status
|
||||
const currentStepIndex = setupSteps.findIndex(
|
||||
(step) => step.key === task?.status,
|
||||
);
|
||||
const stepsWithStatus = setupSteps.map((step, index) => ({
|
||||
...step,
|
||||
completed: index < currentStepIndex,
|
||||
current: index === currentStepIndex,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold text-center">
|
||||
Setting up your conversation
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{stepsWithStatus.map((step, index) => (
|
||||
<div key={step.key} className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-sm ${
|
||||
step.completed
|
||||
? "bg-green-500 text-white"
|
||||
: step.current
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{step.completed ? "✓" : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
step.current
|
||||
? "font-medium text-blue-600"
|
||||
: step.completed
|
||||
? "text-green-600"
|
||||
: "text-gray-500"
|
||||
}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.current && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm text-center">{error.message}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationSetupFlowProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ConversationSetupFlow({
|
||||
conversationId,
|
||||
}: ConversationSetupFlowProps) {
|
||||
const navigate = useNavigate();
|
||||
const [currentTask, setCurrentTask] =
|
||||
useState<AppConversationStartTask | null>(null);
|
||||
const { setCurrentAgentState } = useAgentStore();
|
||||
|
||||
const { mutate: startConversation, error } = useStreamStartAppConversation();
|
||||
|
||||
useEffect(() => {
|
||||
// Set agent state to loading during setup
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
|
||||
// Start the V1 conversation creation
|
||||
const request: AppConversationStartRequest = {
|
||||
// Get from user settings, context, etc.
|
||||
initial_message: {
|
||||
message: "Hello! I'm ready to help you with your project.",
|
||||
image_urls: [],
|
||||
},
|
||||
};
|
||||
|
||||
startConversation({
|
||||
request,
|
||||
onProgress: (task) => {
|
||||
setCurrentTask(task);
|
||||
|
||||
// When ready, replace URL and let existing logic take over
|
||||
if (task.status === "READY" && task.app_conversation_id) {
|
||||
// Replace the URL without the setup parameter
|
||||
navigate(`/conversations/${task.app_conversation_id}`, {
|
||||
replace: true,
|
||||
});
|
||||
// The existing conversation logic will now load the real conversation
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [conversationId, startConversation, setCurrentAgentState, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Empty messages area - could show setup steps here */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ConversationSetupProgress task={currentTask} error={error} />
|
||||
</div>
|
||||
|
||||
{/* Setup progress in place of chat input */}
|
||||
<div className="border-t bg-white p-4">
|
||||
<ConversationSetupInput task={currentTask} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,21 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "../../settings/brand-button";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
|
||||
export function CreateConversationButton() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useCreateConversation();
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
// We check for isCreatingConversationElsewhere to prevent multiple conversations
|
||||
const isCreatingConversation = isCreatingConversationElsewhere;
|
||||
|
||||
const handleCreateConversation = () => {
|
||||
createConversation(
|
||||
{},
|
||||
{
|
||||
onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
);
|
||||
const taskId = crypto.randomUUID();
|
||||
// Navigate with a special setup parameter
|
||||
navigate(`/conversations/${taskId}?setup=true`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isActionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
|
||||
@@ -34,6 +35,9 @@ export function ConversationWebSocketProvider({
|
||||
children: React.ReactNode;
|
||||
conversationId?: string;
|
||||
}) {
|
||||
const { data: userConversation } = useUserConversation(
|
||||
conversationId || null,
|
||||
);
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
|
||||
>("CONNECTING");
|
||||
@@ -81,6 +85,9 @@ export function ConversationWebSocketProvider({
|
||||
|
||||
const websocketOptions = useMemo(
|
||||
() => ({
|
||||
queryParams: {
|
||||
session_api_key: userConversation?.session_api_key || "",
|
||||
},
|
||||
onOpen: () => {
|
||||
setConnectionState("OPEN");
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
@@ -100,13 +107,27 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
}),
|
||||
[handleMessage, setErrorMessage, removeErrorMessage],
|
||||
[
|
||||
userConversation?.session_api_key,
|
||||
handleMessage,
|
||||
setErrorMessage,
|
||||
removeErrorMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const { socket } = useWebSocket(
|
||||
"ws://localhost/events/socket",
|
||||
websocketOptions,
|
||||
);
|
||||
// Extract the host and port from the conversation URL
|
||||
// Expected format: http://localhost:PORT/api/conversations/xxx
|
||||
// We want to construct: ws://localhost:PORT/sockets/events/{conversationId}
|
||||
const socketUrl = useMemo(() => {
|
||||
const url = userConversation?.url || "";
|
||||
const urlMatch = url.match(/^https?:\/\/([^/]+)/);
|
||||
const hostWithPort = urlMatch?.[1] || "localhost";
|
||||
|
||||
return `ws://${hostWithPort}/sockets/events/${conversationId}`;
|
||||
}, [userConversation?.url, conversationId]);
|
||||
|
||||
// Use the custom useWebSocket hook
|
||||
const { socket } = useWebSocket(socketUrl, websocketOptions);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
|
||||
124
frontend/src/hooks/mutation/use-stream-start-app-conversation.ts
Normal file
124
frontend/src/hooks/mutation/use-stream-start-app-conversation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useRef } from "react";
|
||||
import AppConversationService from "#/api/app-conversation-service/app-conversation-service.api";
|
||||
import {
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
} from "#/api/open-hands.types";
|
||||
|
||||
interface StreamStartAppConversationVariables {
|
||||
request: AppConversationStartRequest;
|
||||
onProgress?: (task: AppConversationStartTask) => void;
|
||||
}
|
||||
|
||||
interface StreamStartAppConversationResult {
|
||||
finalTask: AppConversationStartTask | null;
|
||||
allTasks: AppConversationStartTask[];
|
||||
}
|
||||
|
||||
export const useStreamStartAppConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelStream = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["stream-start-app-conversation"],
|
||||
mutationFn: async (
|
||||
variables: StreamStartAppConversationVariables,
|
||||
): Promise<StreamStartAppConversationResult> => {
|
||||
const { request, onProgress } = variables;
|
||||
|
||||
// Create a new AbortController for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const allTasks: AppConversationStartTask[] = [];
|
||||
let finalTask: AppConversationStartTask | null = null;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential processing required for streaming
|
||||
for await (const task of AppConversationService.streamStartAppConversation(
|
||||
request,
|
||||
)) {
|
||||
// Check if the request was aborted
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
throw new Error("Request was cancelled");
|
||||
}
|
||||
|
||||
allTasks.push(task);
|
||||
finalTask = task;
|
||||
|
||||
// Call the progress callback if provided
|
||||
if (onProgress) {
|
||||
onProgress(task);
|
||||
}
|
||||
|
||||
// If we reach READY or ERROR status, we're done
|
||||
if (task.status === "READY" || task.status === "ERROR") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's not a cancellation error, re-throw it
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message !== "Request was cancelled"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
// For cancellation, we still return what we have so far
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
return { finalTask, allTasks };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
// Invalidate relevant queries when the conversation is successfully started
|
||||
if (result.finalTask?.status === "READY") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["app-conversations"],
|
||||
});
|
||||
|
||||
// You might also want to invalidate other related queries
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error starting app conversation:", error);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
cancelStream,
|
||||
isStreaming: mutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
// Additional hook for simpler usage when you just want the final result
|
||||
export const useStartAppConversation = () => {
|
||||
const streamMutation = useStreamStartAppConversation();
|
||||
|
||||
const startConversation = useCallback(
|
||||
(request: AppConversationStartRequest) =>
|
||||
streamMutation.mutateAsync({ request }),
|
||||
[streamMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
startConversation,
|
||||
isLoading: streamMutation.isPending,
|
||||
error: streamMutation.error,
|
||||
data: streamMutation.data,
|
||||
reset: streamMutation.reset,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
@@ -28,17 +28,38 @@ import { ConversationName } from "#/components/features/conversation/conversatio
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
|
||||
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { setIsSetupMode, setConversationId } = useConversationSetupStore();
|
||||
|
||||
// Check if we're in setup mode
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isSetupMode = searchParams.get("setup") === "true";
|
||||
|
||||
// Update the store when setup mode changes
|
||||
React.useEffect(() => {
|
||||
setIsSetupMode(isSetupMode);
|
||||
setConversationId(conversationId);
|
||||
}, [isSetupMode, conversationId, setIsSetupMode, setConversationId]);
|
||||
|
||||
// Only fetch conversation if NOT in setup mode
|
||||
const {
|
||||
data: conversation,
|
||||
isFetched,
|
||||
refetch,
|
||||
} = useActiveConversation({
|
||||
enabled: !isSetupMode,
|
||||
});
|
||||
const { mutate: startConversation } = useStartConversation();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
const { resetConversationState } = useConversationStore();
|
||||
const navigate = useNavigate();
|
||||
const clearTerminal = useCommandStore((state) => state.clearTerminal);
|
||||
const setCurrentAgentState = useAgentStore(
|
||||
(state) => state.setCurrentAgentState,
|
||||
@@ -59,8 +80,9 @@ function AppContent() {
|
||||
});
|
||||
}, [conversationId, queryClient]);
|
||||
|
||||
// Modified guard logic - don't redirect if in setup mode
|
||||
React.useEffect(() => {
|
||||
if (isFetched && !conversation && isAuthed) {
|
||||
if (!isSetupMode && isFetched && !conversation && isAuthed) {
|
||||
displayErrorToast(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
@@ -79,6 +101,7 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isSetupMode,
|
||||
conversation?.conversation_id,
|
||||
conversation?.status,
|
||||
isFetched,
|
||||
@@ -105,8 +128,13 @@ function AppContent() {
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
});
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
return (
|
||||
<WebSocketProviderWrapper version={0} conversationId={conversationId}>
|
||||
<WebSocketProviderWrapper
|
||||
version={isV1Conversation ? 1 : 0}
|
||||
conversationId={conversationId}
|
||||
>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div
|
||||
|
||||
28
frontend/src/stores/conversation-setup-store.ts
Normal file
28
frontend/src/stores/conversation-setup-store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { create } from "zustand";
|
||||
import { AppConversationStartTask } from "#/api/open-hands.types";
|
||||
|
||||
interface ConversationSetupStore {
|
||||
isSetupMode: boolean;
|
||||
currentTask: AppConversationStartTask | null;
|
||||
conversationId: string | null;
|
||||
setIsSetupMode: (isSetupMode: boolean) => void;
|
||||
setCurrentTask: (task: AppConversationStartTask | null) => void;
|
||||
setConversationId: (id: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
isSetupMode: false,
|
||||
currentTask: null,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export const useConversationSetupStore = create<ConversationSetupStore>(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setIsSetupMode: (isSetupMode) => set({ isSetupMode }),
|
||||
setCurrentTask: (task) => set({ currentTask: task }),
|
||||
setConversationId: (id) => set({ conversationId: id }),
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user