288 lines
9.8 KiB
TypeScript

import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages as V0Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useEventStore } from "#/stores/use-event-store";
import { ErrorMessageBanner } from "./error-message-banner";
import {
hasUserEvent,
shouldRenderEvent,
} from "./event-content-helpers/should-render-event";
import {
Messages as V1Messages,
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import {
isV0Event,
isV1Event,
isSystemPromptEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
function getEntryPoint(
hasRepository: boolean | null,
hasReplayJson: boolean | null,
): string {
if (hasRepository) return "github";
if (hasReplayJson) return "replay";
return "direct";
}
export function ChatInterface() {
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
const { isLoadingMessages } = useWsClient();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
const { t } = useTranslation();
const scrollRef = React.useRef<HTMLDivElement>(null);
const {
scrollDomToBottom,
onChatBodyScroll,
hitBottom,
autoScroll,
setAutoScroll,
setHitBottom,
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useAgentState();
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
const isV1Conversation = conversation?.conversation_version === "V1";
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1Events.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.some(
(event) =>
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
) ||
storeEvents
.filter(isV1Event)
.some(
(event) =>
event.source === "agent" &&
!isSystemPromptEvent(event) &&
!isConversationStateUpdateEvent(event),
),
[storeEvents],
);
const handleSendMessage = async (
content: string,
originalImages: File[],
originalFiles: File[],
) => {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (totalEvents === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
selectedRepository !== null,
replayJson !== null,
),
query_character_length: content.length,
replay_json_size: replayJson?.length,
});
} else {
posthog.capture("user_message_sent", {
session_message_count: totalEvents,
current_message_length: content.length,
});
}
// Validate file sizes before any processing
const allFiles = [...images, ...files];
const validation = validateFiles(allFiles);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Stop processing if validation fails
}
const promises = images.map((image) => convertImageToBase64(image));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
files.length > 0
? await uploadFiles({ conversationId: params.conversationId!, files })
: { skipped_files: [], uploaded_files: [] };
skippedFiles.forEach((f) => displayErrorToast(f.reason));
const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`;
const prompt =
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
setOptimisticUserMessage(content);
setMessageToSend("");
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
// Create a ScrollProvider with the scroll hook values
const scrollProviderValue = {
scrollRef,
autoScroll,
setAutoScroll,
scrollDomToBottom,
hitBottom,
setHitBottom,
onChatBodyScroll,
};
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1Events);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
{!hasSubstantiveAgentActions &&
!optimisticUserMessage &&
!userEventsExist && (
<ChatSuggestions
onSuggestionsClick={(message) => setMessageToSend(message)}
/>
)}
{/* Note: We only hide chat suggestions when there's a user message */}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
>
{isLoadingMessages && !isV1Conversation && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{v1UserEventsExist && (
<V1Messages
messages={v1Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
</div>
<div className="flex flex-col gap-[6px]">
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<ConfirmationModeEnabled />
{totalEvents > 0 && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
isSaasMode={config?.APP_MODE === "saas"}
/>
)}
</div>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
<InteractiveChatBox onSubmit={handleSendMessage} />
</div>
{config?.APP_MODE !== "saas" && (
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
)}
</div>
</ScrollProvider>
);
}