mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend/copilot): add output action buttons (upvote, downvote) with Langfuse feedback (#12260)
## Summary
- Feedback is submitted to the backend Langfuse integration
(`/api/chat/sessions/{id}/feedback`) for observability
- Downvote opens a modal dialog for optional detailed feedback text (max
2000 chars)
- Buttons are hidden during streaming and appear on hover; once feedback
is selected they stay visible
## Changes
- **`AssistantMessageActions.tsx`** (new): Renders copy (CopySimple),
thumbs-up, and thumbs-down buttons using `MessageAction` from the design
system. Visual states for selected feedback (green for upvote, red for
downvote with filled icons).
- **`FeedbackModal.tsx`** (new): Dialog with a textarea for optional
downvote comment, using the design system `Dialog` component.
- **`useMessageFeedback.ts`** (new): Hook managing per-message feedback
state and backend submission via `POST
/api/chat/sessions/{id}/feedback`.
- **`ChatMessagesContainer.tsx`** (modified): Renders
`AssistantMessageActions` after `MessageContent` for assistant messages
when not streaming.
- **`ChatContainer.tsx`** (modified): Passes `sessionID` prop through to
`ChatMessagesContainer`.
## Test plan
- [ ] Verify action buttons appear on hover over assistant messages
- [ ] Verify buttons are hidden during active streaming
- [ ] Click copy button → text copied to clipboard, success toast shown
- [ ] Click upvote → green highlight, "Thank you" toast, button locked
- [ ] Click downvote → red highlight, feedback modal opens
- [ ] Submit feedback modal with/without comment → modal closes,
feedback sent
- [ ] Cancel feedback modal → modal closes, downvote stays locked
- [ ] Verify feedback POST reaches `/api/chat/sessions/{id}/feedback`
### Linear issue
Closes SECRT-2051
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ export const ChatContainer = ({
|
||||
error={error}
|
||||
isLoading={isLoadingSession}
|
||||
headerSlot={headerSlot}
|
||||
sessionID={sessionId}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -3,19 +3,14 @@ import {
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { parseSpecialMarkers } from "./helpers";
|
||||
import { AssistantMessageActions } from "./components/AssistantMessageActions";
|
||||
import { MessageAttachments } from "./components/MessageAttachments";
|
||||
import { MessagePartRenderer } from "./components/MessagePartRenderer";
|
||||
import { ThinkingIndicator } from "./components/ThinkingIndicator";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { TTSButton } from "./components/TTSButton";
|
||||
import { parseSpecialMarkers } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
@@ -23,6 +18,7 @@ interface Props {
|
||||
error: Error | undefined;
|
||||
isLoading: boolean;
|
||||
headerSlot?: React.ReactNode;
|
||||
sessionID?: string | null;
|
||||
}
|
||||
|
||||
export function ChatMessagesContainer({
|
||||
@@ -31,6 +27,7 @@ export function ChatMessagesContainer({
|
||||
error,
|
||||
isLoading,
|
||||
headerSlot,
|
||||
sessionID,
|
||||
}: Props) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
@@ -80,14 +77,25 @@ 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 textParts = message.parts.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
);
|
||||
const lastTextPart = textParts[textParts.length - 1];
|
||||
const hasErrorMarker =
|
||||
lastTextPart !== undefined &&
|
||||
parseSpecialMarkers(lastTextPart.text).markerType === "error";
|
||||
const showActions =
|
||||
isLastInTurn &&
|
||||
!isCurrentlyStreaming &&
|
||||
textParts.length > 0 &&
|
||||
!hasErrorMarker;
|
||||
|
||||
const fileParts = message.parts.filter(
|
||||
(p): p is FileUIPart => p.type === "file",
|
||||
@@ -120,30 +128,12 @@ export function ChatMessagesContainer({
|
||||
isUser={message.role === "user"}
|
||||
/>
|
||||
)}
|
||||
{isAssistantDone &&
|
||||
(() => {
|
||||
const textParts = message.parts.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||
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 (
|
||||
<MessageActions>
|
||||
<CopyButton text={textContent} />
|
||||
<TTSButton text={textContent} />
|
||||
</MessageActions>
|
||||
);
|
||||
})()}
|
||||
{showActions && (
|
||||
<AssistantMessageActions
|
||||
message={message}
|
||||
sessionID={sessionID ?? null}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<unknown, UIDataTypes, UITools>;
|
||||
sessionID: string | null;
|
||||
}
|
||||
|
||||
function extractTextFromParts(
|
||||
parts: UIMessage<unknown, UIDataTypes, UITools>["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 (
|
||||
<>
|
||||
<MessageActions className="mt-1">
|
||||
<MessageAction
|
||||
tooltip="Copy"
|
||||
onClick={() => handleCopy(text)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
>
|
||||
<CopySimple size={16} weight="regular" />
|
||||
</MessageAction>
|
||||
|
||||
<MessageAction
|
||||
tooltip="Good response"
|
||||
onClick={handleUpvote}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={feedback === "downvote"}
|
||||
className={cn(
|
||||
feedback === "upvote" && "text-green-300 hover:text-green-300",
|
||||
feedback === "downvote" && "!opacity-20",
|
||||
)}
|
||||
>
|
||||
<ThumbsUp
|
||||
size={16}
|
||||
weight={feedback === "upvote" ? "fill" : "regular"}
|
||||
/>
|
||||
</MessageAction>
|
||||
|
||||
<MessageAction
|
||||
tooltip="Bad response"
|
||||
onClick={handleDownvoteClick}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={feedback === "upvote"}
|
||||
className={cn(
|
||||
feedback === "downvote" && "text-red-300 hover:text-red-300",
|
||||
feedback === "upvote" && "!opacity-20",
|
||||
)}
|
||||
>
|
||||
<ThumbsDown
|
||||
size={16}
|
||||
weight={feedback === "downvote" ? "fill" : "regular"}
|
||||
/>
|
||||
</MessageAction>
|
||||
|
||||
<TTSButton text={text} />
|
||||
</MessageActions>
|
||||
|
||||
{showFeedbackModal && (
|
||||
<FeedbackModal
|
||||
isOpen={showFeedbackModal}
|
||||
onSubmit={handleDownvoteSubmit}
|
||||
onCancel={handleDownvoteCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"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() {
|
||||
onSubmit(comment);
|
||||
setComment("");
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onCancel();
|
||||
setComment("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="What could have been better?"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) handleClose();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="mx-auto w-[95%] space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Your feedback helps us improve. Share details below.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Tell us what went wrong or could be improved..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400">{comment.length}/2000</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import { getWebSocketToken } from "@/lib/supabase/actions";
|
||||
import { environment } from "@/services/environment";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Args {
|
||||
sessionID: string | null;
|
||||
messageID: string;
|
||||
}
|
||||
|
||||
async function submitFeedbackToBackend(args: {
|
||||
sessionID: string;
|
||||
messageID: string;
|
||||
scoreName: string;
|
||||
scoreValue: number;
|
||||
comment?: string;
|
||||
}) {
|
||||
try {
|
||||
const { token } = await getWebSocketToken();
|
||||
if (!token) return;
|
||||
|
||||
await fetch(
|
||||
`${environment.getAGPTServerBaseUrl()}/api/chat/sessions/${args.sessionID}/feedback`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_id: args.messageID,
|
||||
score_name: args.scoreName,
|
||||
score_value: args.scoreValue,
|
||||
comment: args.comment,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Feedback submission is best-effort; silently ignore failures
|
||||
}
|
||||
}
|
||||
|
||||
export function useMessageFeedback({ sessionID, messageID }: Args) {
|
||||
const [feedback, setFeedback] = useState<"upvote" | "downvote" | null>(null);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
|
||||
async function handleCopy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: "Copied!", variant: "success", duration: 2000 });
|
||||
} catch {
|
||||
toast({
|
||||
title: "Failed to copy",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sessionID) {
|
||||
submitFeedbackToBackend({
|
||||
sessionID,
|
||||
messageID,
|
||||
scoreName: "copy",
|
||||
scoreValue: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpvote() {
|
||||
if (feedback) return;
|
||||
setFeedback("upvote");
|
||||
toast({
|
||||
title: "Thank you for your feedback!",
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
if (sessionID) {
|
||||
submitFeedbackToBackend({
|
||||
sessionID,
|
||||
messageID,
|
||||
scoreName: "user-feedback",
|
||||
scoreValue: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownvoteClick() {
|
||||
if (feedback) return;
|
||||
setFeedback("downvote");
|
||||
setShowFeedbackModal(true);
|
||||
}
|
||||
|
||||
function handleDownvoteSubmit(comment: string) {
|
||||
setShowFeedbackModal(false);
|
||||
if (sessionID) {
|
||||
submitFeedbackToBackend({
|
||||
sessionID,
|
||||
messageID,
|
||||
scoreName: "user-feedback",
|
||||
scoreValue: 0,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownvoteCancel() {
|
||||
setShowFeedbackModal(false);
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return {
|
||||
feedback,
|
||||
showFeedbackModal,
|
||||
handleCopy,
|
||||
handleUpvote,
|
||||
handleDownvoteClick,
|
||||
handleDownvoteSubmit,
|
||||
handleDownvoteCancel,
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@/lib/streamdown-code-plugin";
|
||||
@@ -89,14 +88,10 @@ export const MessageAction = ({
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user