diff --git a/frontend/src/components/chat/ChatInterface.tsx b/frontend/src/components/chat/ChatInterface.tsx index c12b213b58..36d135de53 100644 --- a/frontend/src/components/chat/ChatInterface.tsx +++ b/frontend/src/components/chat/ChatInterface.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { twMerge } from "tailwind-merge"; import { VscArrowDown } from "react-icons/vsc"; import { FaRegThumbsDown, FaRegThumbsUp } from "react-icons/fa"; +import { useDisclosure } from "@nextui-org/react"; import ChatInput from "./ChatInput"; import Chat from "./Chat"; import { RootState } from "#/store"; @@ -14,11 +15,11 @@ import { sendChatMessage } from "#/services/chatService"; import { addUserMessage, addAssistantMessage } from "#/state/chatSlice"; import { I18nKey } from "#/i18n/declaration"; import { useScrollToBottom } from "#/hooks/useScrollToBottom"; +import { Feedback } from "#/services/feedbackService"; +import FeedbackModal from "../modals/feedback/FeedbackModal"; +import { removeApiKey } from "#/utils/utils"; import Session from "#/services/session"; import { getToken } from "#/services/auth"; -import toast from "#/utils/toast"; -import { removeApiKey } from "#/utils/utils"; -import { FeedbackData, sendFeedback } from "#/services/feedbackService"; interface ScrollButtonProps { onClick: () => void; @@ -53,31 +54,32 @@ function ChatInterface() { const { messages } = useSelector((state: RootState) => state.chat); const { curAgentState } = useSelector((state: RootState) => state.agent); - const [feedbackShared, setFeedbackShared] = React.useState(false); - const [feedbackLoading, setFeedbackLoading] = React.useState(false); + const feedbackVersion = "1.0"; + const [feedback, setFeedback] = React.useState({ + email: "", + feedback: "positive", + permissions: "private", + trajectory: [], + token: "", + version: feedbackVersion, + }); + const [feedbackShared, setFeedbackShared] = React.useState(0); - const shareFeedback = async (feedback: "positive" | "negative") => { - // TODO: implement email and permissions - // https://github.com/OpenDevin/OpenDevin/issues/2033 - const data: FeedbackData = { - email: "NOT_PROVIDED", - token: getToken(), - feedback, - permissions: "private", + const { + isOpen: feedbackModalIsOpen, + onOpen: onFeedbackModalOpen, + onOpenChange: onFeedbackModalOpenChange, + } = useDisclosure(); + + const shareFeedback = async (polarity: "positive" | "negative") => { + setFeedbackShared(messages.length); + setFeedback((prev) => ({ + ...prev, + feedback: polarity, trajectory: removeApiKey(Session._history), - }; - - try { - setFeedbackLoading(true); - await sendFeedback(data); - toast.info("Feedback shared successfully."); - } catch (e) { - console.error(e); - toast.error("share-error", "Failed to share, see console for details."); - } finally { - setFeedbackShared(true); - setFeedbackLoading(false); - } + token: getToken(), + })); + onFeedbackModalOpen(); }; const handleSendMessage = (content: string) => { @@ -85,6 +87,14 @@ function ChatInterface() { sendChatMessage(content); }; + const handleEmailChange = (key: string) => { + setFeedback({ ...feedback, email: key } as Feedback); + }; + + const handlePermissionsChange = (permissions: "public" | "private") => { + setFeedback({ ...feedback, permissions } as Feedback); + }; + const { t } = useTranslation(); const handleSendContinueMsg = () => { handleSendMessage(t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE)); @@ -99,7 +109,7 @@ function ChatInterface() { if (curAgentState === AgentState.INIT && messages.length === 0) { dispatch(addAssistantMessage(t(I18nKey.CHAT_INTERFACE$INITIAL_MESSAGE))); } - }, [curAgentState]); + }, [curAgentState, dispatch, messages.length, t]); return (
@@ -142,16 +152,14 @@ function ChatInterface() { })}
- {!feedbackShared && messages.length > 3 && ( + {feedbackShared !== messages.length && messages.length > 3 && (
shareFeedback("positive")} icon={} label="" /> shareFeedback("negative")} icon={} label="" @@ -164,6 +172,13 @@ function ChatInterface() { disabled={curAgentState === AgentState.LOADING} onSendMessage={handleSendMessage} /> +
); } diff --git a/frontend/src/components/modals/feedback/FeedbackForm.tsx b/frontend/src/components/modals/feedback/FeedbackForm.tsx new file mode 100644 index 0000000000..d1b8edf6ec --- /dev/null +++ b/frontend/src/components/modals/feedback/FeedbackForm.tsx @@ -0,0 +1,63 @@ +import { Input, Select, SelectItem } from "@nextui-org/react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "../../../i18n/declaration"; +import { Feedback } from "#/services/feedbackService"; + +interface FeedbackFormProps { + feedback: Feedback; + + onEmailChange: (email: string) => void; + onPermissionsChange: (permissions: "public" | "private") => void; +} + +function FeedbackForm({ + feedback, + onEmailChange, + onPermissionsChange, +}: FeedbackFormProps) { + const { t } = useTranslation(); + + const isEmailValid = (email: string) => { + // Regular expression to validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + return ( + <> + { + onEmailChange(e.target.value); + }} + /> + + {isEmailValid(feedback.email) ? null : ( +

Invalid email format

+ )} + + ); +} + +export default FeedbackForm; diff --git a/frontend/src/components/modals/feedback/FeedbackModal.tsx b/frontend/src/components/modals/feedback/FeedbackModal.tsx new file mode 100644 index 0000000000..7257aca18d --- /dev/null +++ b/frontend/src/components/modals/feedback/FeedbackModal.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import BaseModal from "../base-modal/BaseModal"; +import { Feedback, sendFeedback } from "#/services/feedbackService"; +import FeedbackForm from "./FeedbackForm"; +import toast from "#/utils/toast"; + +interface FeedbackModalProps { + feedback: Feedback; + handleEmailChange: (key: string) => void; + handlePermissionsChange: (permissions: "public" | "private") => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +function FeedbackModal({ + feedback, + handleEmailChange, + handlePermissionsChange, + isOpen, + onOpenChange, +}: FeedbackModalProps) { + const { t } = useTranslation(); + + const handleSendFeedback = () => { + sendFeedback(feedback) + .then((response) => { + if (response.message === "Feedback submitted successfully") { + toast.info(response.message); + } else { + toast.error( + "share-error", + `Failed to share, please contact the developers: ${response.message}`, + ); + } + }) + .catch((error) => { + toast.error( + "share-error", + `Failed to share, please contact the developers: ${error}`, + ); + }); + }; + + return ( + +

{t(I18nKey.FEEDBACK$MODAL_CONTENT)}

+ +
+ ); +} + +export default FeedbackModal; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 2ebc3fff41..baed1b5c24 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -292,6 +292,27 @@ "zh-CN": "开始新会话", "zh-TW": "開始新會話" }, + "FEEDBACK$MODAL_TITLE": { + "en": "Share feedback" + }, + "FEEDBACK$MODAL_CONTENT": { + "en": "Thank you for your feedback! The history of this conversation, together with your positive/negative feedback will be shared with the OpenDevin developers. Additionally, you can choose to contribute this feedback to our public dataset that will be shared with the community for model improvements." + }, + "FEEDBACK$EMAIL_LABEL": { + "en": "Your email" + }, + "FEEDBACK$CONTRIBUTE_LABEL": { + "en": "Contribute to public dataset" + }, + "FEEDBACK$SHARE_LABEL": { + "en": "Share" + }, + "FEEDBACK$CANCEL_LABEL": { + "en": "Cancel" + }, + "FEEDBACK$EMAIL_PLACEHOLDER": { + "en": "Enter your email address." + }, "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": { "en": "Initializing agent (may take up to 10 seconds)...", "zh-CN": "正在初始化智能体(可能需要 10 秒以上时间)", diff --git a/frontend/src/services/feedbackService.ts b/frontend/src/services/feedbackService.ts index 0dc2d667ea..8fa8ca1f28 100644 --- a/frontend/src/services/feedbackService.ts +++ b/frontend/src/services/feedbackService.ts @@ -1,6 +1,7 @@ import { request } from "./api"; -export interface FeedbackData { +export interface Feedback { + version: string; email: string; token: string; feedback: "positive" | "negative"; @@ -8,8 +9,8 @@ export interface FeedbackData { trajectory: unknown[]; } -export async function sendFeedback(data: FeedbackData) { - await request("/api/submit-feedback", { +export async function sendFeedback(data: Feedback) { + return request("/api/submit-feedback", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/opendevin/server/data_models/feedback.py b/opendevin/server/data_models/feedback.py index cf0c2651a4..494d50219c 100644 --- a/opendevin/server/data_models/feedback.py +++ b/opendevin/server/data_models/feedback.py @@ -7,6 +7,7 @@ from opendevin.core.logger import opendevin_logger as logger class FeedbackDataModel(BaseModel): + version: str email: str token: str feedback: Literal['positive', 'negative'] @@ -20,7 +21,16 @@ FEEDBACK_URL = ( def store_feedback(feedback: FeedbackDataModel): - logger.info(f'Got feedback: {feedback.model_dump_json()}') + # Start logging + display_feedback = feedback.model_dump() + if 'trajectory' in display_feedback: + display_feedback['trajectory'] = ( + f"elided [length: {len(display_feedback['trajectory'])}" + ) + if 'token' in display_feedback: + display_feedback['token'] = 'elided' + logger.info(f'Got feedback: {display_feedback}') + # Start actual request response = requests.post( FEEDBACK_URL, headers={'Content-Type': 'application/json'},