From 96a3bb9aa28c774a125b86f7acdbc7e82104aaa0 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Mon, 2 Mar 2026 19:10:32 +0800 Subject: [PATCH 1/7] feat(frontend/copilot): add output action buttons (copy, upvote, downvote) with Langfuse feedback Add copy, upvote, and downvote action buttons to assistant messages in the CoPilot chat. Feedback is submitted to the backend Langfuse integration for observability. Downvote opens a modal for optional detailed feedback. - AssistantMessageActions: renders copy/upvote/downvote with hover reveal - FeedbackModal: dialog for optional downvote comment (max 2000 chars) - useMessageFeedback: manages feedback state and backend submission - Actions hidden during streaming, visible on hover or after selection Co-Authored-By: Claude Opus 4.6 --- .../ChatContainer/ChatContainer.tsx | 1 + .../ChatMessagesContainer.tsx | 61 +++------ .../components/AssistantMessageActions.tsx | 100 +++++++++++++++ .../components/FeedbackModal.tsx | 70 ++++++++++ .../useMessageFeedback.ts | 120 ++++++++++++++++++ .../src/components/ai-elements/message.tsx | 15 +-- 6 files changed, 317 insertions(+), 50 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/AssistantMessageActions.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/useMessageFeedback.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx index 3ec761183a51..8178cc7421ea 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx @@ -64,6 +64,7 @@ export const ChatContainer = ({ error={error} isLoading={isLoadingSession} headerSlot={headerSlot} + sessionID={sessionId} /> []; @@ -23,6 +17,7 @@ interface Props { error: Error | undefined; isLoading: boolean; headerSlot?: React.ReactNode; + sessionID?: string | null; } export function ChatMessagesContainer({ @@ -31,6 +26,7 @@ export function ChatMessagesContainer({ error, isLoading, headerSlot, + sessionID, }: Props) { const lastMessage = messages[messages.length - 1]; @@ -80,14 +76,17 @@ export function ChatMessagesContainer({ messageIndex === messages.length - 1 && message.role === "assistant"; - const isAssistant = message.role === "assistant"; - - // Past assistant messages are always done; the last one - // is done only when the stream has finished. - const isAssistantDone = - isAssistant && - (!isLastAssistant || - (status !== "streaming" && status !== "submitted")); + const isCurrentlyStreaming = + isLastAssistant && + (status === "streaming" || status === "submitted"); + const nextMessage = messages[messageIndex + 1]; + const isLastInTurn = + message.role === "assistant" && + (!nextMessage || nextMessage.role === "user"); + const showActions = + isLastInTurn && + !isCurrentlyStreaming && + message.parts.some((p) => p.type === "text"); const fileParts = message.parts.filter( (p): p is FileUIPart => p.type === "file", @@ -120,30 +119,12 @@ export function ChatMessagesContainer({ isUser={message.role === "user"} /> )} - {isAssistantDone && - (() => { - const textParts = message.parts.filter( - (p): p is Extract => - p.type === "text", - ); - - // Hide actions when the message ended with an error or cancellation - const lastTextPart = textParts[textParts.length - 1]; - if (lastTextPart) { - const { markerType } = parseSpecialMarkers( - lastTextPart.text, - ); - if (markerType === "error") return null; - } - - const textContent = textParts.map((p) => p.text).join("\n"); - return ( - - - - - ); - })()} + {showActions && ( + + )} ); })} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/AssistantMessageActions.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/AssistantMessageActions.tsx new file mode 100644 index 000000000000..2409a51bfb6a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/AssistantMessageActions.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + MessageAction, + MessageActions, +} from "@/components/ai-elements/message"; +import { cn } from "@/lib/utils"; +import { CopySimple, ThumbsDown, ThumbsUp } from "@phosphor-icons/react"; +import { UIDataTypes, UIMessage, UITools } from "ai"; +import { useMessageFeedback } from "../useMessageFeedback"; +import { FeedbackModal } from "./FeedbackModal"; +import { TTSButton } from "./TTSButton"; + +interface Props { + message: UIMessage; + sessionID: string | null; +} + +function extractTextFromParts( + parts: UIMessage["parts"], +): string { + return parts + .filter((p) => p.type === "text") + .map((p) => (p as { type: "text"; text: string }).text) + .join("\n") + .trim(); +} + +export function AssistantMessageActions({ message, sessionID }: Props) { + const { + feedback, + showFeedbackModal, + handleCopy, + handleUpvote, + handleDownvoteClick, + handleDownvoteSubmit, + handleDownvoteCancel, + } = useMessageFeedback({ sessionID, messageID: message.id }); + + const text = extractTextFromParts(message.parts); + + return ( + <> + + handleCopy(text)} + variant="ghost" + size="icon-sm" + > + + + + + + + + + + + + + + + {showFeedbackModal && ( + + )} + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx new file mode 100644 index 000000000000..8c773b1aaf12 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useState } from "react"; + +interface Props { + isOpen: boolean; + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) { + const [comment, setComment] = useState(""); + + function handleSubmit() { + if (!comment.trim()) return; + onSubmit(comment); + setComment(""); + } + + function handleClose() { + onCancel(); + setComment(""); + } + + return ( + { + if (!open) handleClose(); + }, + }} + > + +
+

+ Your feedback helps us improve. Share details below. +

+