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:
Ubbe
2026-03-05 17:01:05 +08:00
committed by GitHub
parent f1b771b7ee
commit 5474f7c495
6 changed files with 321 additions and 50 deletions

View File

@@ -64,6 +64,7 @@ export const ChatContainer = ({
error={error}
isLoading={isLoadingSession}
headerSlot={headerSlot}
sessionID={sessionId}
/>
<motion.div
initial={{ opacity: 0 }}

View File

@@ -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>
);
})}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}