feat(frontend): strict feedback toggle and thumbs-down reason dialog

One rating at a time: the other thumb is hidden while a rating exists
and must be retracted before switching. Thumbs-down stores the rating
immediately, then opens a follow-up dialog (reason chips + optional
details) submitted as a second idempotent PUT. Reason slugs are
language-neutral; labels localize via the i18n catalog. The buttons move
to the assistant turn action bar next to copy/regenerate.
This commit is contained in:
rayhpeng 2026-07-23 15:31:07 +08:00
parent c9e6aa830b
commit 86f93a18cc
7 changed files with 260 additions and 30 deletions

View File

@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { FEEDBACK_TAG_SLUGS, type FeedbackTagSlug } from "@/core/api/feedback";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { Button } from "../../ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../ui/dialog";
import { Textarea } from "../../ui/textarea";
/** Maps language-neutral tag slugs to their i18n label keys. */
const TAG_LABEL_KEYS: Record<FeedbackTagSlug, "incorrect" | "notAsExpected" | "slow" | "styleTone" | "safetyLegal" | "other"> = {
incorrect: "incorrect",
not_as_expected: "notAsExpected",
slow: "slow",
style_tone: "styleTone",
safety_legal: "safetyLegal",
other: "other",
};
/**
* Thumbs-down follow-up dialog (ChatGPT-style): reason chips + optional
* details. The rating itself is already stored when this opens; submitting
* issues a second idempotent PUT that enriches the same record with
* tags/comment. Closing without submitting keeps the plain thumbs-down.
*/
export function FeedbackDialog({
open,
onOpenChange,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (tags: string[], comment: string) => Promise<void>;
}) {
const { t } = useI18n();
const [selected, setSelected] = useState<Set<FeedbackTagSlug>>(new Set());
const [comment, setComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const toggleTag = (slug: FeedbackTagSlug) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
return next;
});
};
const canSubmit = (selected.size > 0 || comment.trim().length > 0) && !isSubmitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setIsSubmitting(true);
try {
await onSubmit([...selected], comment.trim());
onOpenChange(false);
setSelected(new Set());
setComment("");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t.feedback.dialogTitle}</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap gap-2">
{FEEDBACK_TAG_SLUGS.map((slug) => (
<button
key={slug}
type="button"
onClick={() => toggleTag(slug)}
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
selected.has(slug)
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-muted",
)}
>
{t.feedback.tags[TAG_LABEL_KEYS[slug]]}
</button>
))}
</div>
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder={t.feedback.detailsPlaceholder}
rows={4}
/>
<DialogFooter>
<Button type="button" onClick={handleSubmit} disabled={!canSubmit}>
{t.feedback.submit}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -60,10 +60,11 @@ import { CopyButton } from "../copy-button";
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
import { SlashSkillChip } from "../slash-skill-chip";
import { FeedbackDialog } from "./feedback-dialog";
import { MarkdownContent } from "./markdown-content";
import { createMarkdownLinkComponent } from "./markdown-link";
function FeedbackButtons({
export function FeedbackButtons({
threadId,
runId,
initialFeedback,
@ -72,22 +73,32 @@ function FeedbackButtons({
runId: string;
initialFeedback: FeedbackData | null;
}) {
const { t } = useI18n();
const [feedback, setFeedback] = useState<FeedbackData | null>(
initialFeedback,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
// Strict mutual exclusion: once a rating exists, only its own (highlighted)
// button stays visible; the user must retract it before rating the other
// way. No direct up<->down switching.
const handleClick = useCallback(
async (rating: number) => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
if (feedback?.rating === rating) {
if (feedback) {
// Only the active button is rendered, so this is always a retract.
await deleteFeedback(threadId, runId);
setFeedback(null);
} else {
const result = await upsertFeedback(threadId, runId, rating);
setFeedback(result);
if (rating === -1) {
// Rating is already stored; the dialog only enriches it.
setDialogOpen(true);
}
}
} catch {
// Revert on error — feedback state unchanged on catch
@ -98,34 +109,59 @@ function FeedbackButtons({
[threadId, runId, feedback, isSubmitting],
);
const handleDialogSubmit = useCallback(
async (tags: string[], comment: string) => {
const result = await upsertFeedback(
threadId,
runId,
-1,
comment.length > 0 ? comment : undefined,
tags,
);
setFeedback(result);
},
[threadId, runId],
);
return (
<div className="flex gap-1">
<button
type="button"
className={cn(
"text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors",
feedback?.rating === 1 && "text-foreground",
)}
onClick={() => handleClick(1)}
disabled={isSubmitting}
>
<ThumbsUpIcon
className={cn("size-4", feedback?.rating === 1 && "fill-current")}
/>
</button>
<button
type="button"
className={cn(
"text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors",
feedback?.rating === -1 && "text-foreground",
)}
onClick={() => handleClick(-1)}
disabled={isSubmitting}
>
<ThumbsDownIcon
className={cn("size-4", feedback?.rating === -1 && "fill-current")}
/>
</button>
{(!feedback || feedback.rating === 1) && (
<button
type="button"
aria-label={t.feedback.thumbsUp}
className={cn(
"text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors",
feedback?.rating === 1 && "text-foreground",
)}
onClick={() => handleClick(1)}
disabled={isSubmitting}
>
<ThumbsUpIcon
className={cn("size-4", feedback?.rating === 1 && "fill-current")}
/>
</button>
)}
{(!feedback || feedback.rating === -1) && (
<button
type="button"
aria-label={t.feedback.thumbsDown}
className={cn(
"text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors",
feedback?.rating === -1 && "text-foreground",
)}
onClick={() => handleClick(-1)}
disabled={isSubmitting}
>
<ThumbsDownIcon
className={cn("size-4", feedback?.rating === -1 && "fill-current")}
/>
</button>
)}
<FeedbackDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleDialogSubmit}
/>
</div>
);
}

View File

@ -28,6 +28,7 @@ import {
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Button } from "@/components/ui/button";
import type { FeedbackData } from "@/core/api/feedback";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -83,7 +84,7 @@ import {
} from "./human-input-card";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { FeedbackButtons, MessageListItem } from "./message-list-item";
import {
MessageTokenUsageDebugList,
MessageTokenUsageList,
@ -738,10 +739,26 @@ export function MessageList({
if (!clipboardData && !actionTarget) {
return null;
}
// Feedback echoes back on the run's last AI message (attached by the
// message-list endpoints); the turn's action bar owns the thumbs.
const feedbackTarget = actionTarget as
| { run_id?: string; feedback?: FeedbackData | null }
| undefined;
const feedbackRunId =
typeof feedbackTarget?.run_id === "string"
? feedbackTarget.run_id
: undefined;
return (
<div className="mt-2 flex justify-start gap-1 opacity-0 transition-opacity delay-200 duration-300 group-hover/assistant-turn:opacity-100">
{clipboardData && <CopyButton clipboardData={clipboardData} />}
{!isStreaming && feedbackRunId && (
<FeedbackButtons
threadId={threadId}
runId={feedbackRunId}
initialFeedback={feedbackTarget?.feedback ?? null}
/>
)}
{enableBranchForTurn &&
!isStreaming &&
actionTarget?.id &&
@ -824,6 +841,7 @@ export function MessageList({
regeneratingMessageId,
t.common.branch,
t.common.regenerate,
threadId,
],
);

View File

@ -6,20 +6,38 @@ export interface FeedbackData {
feedback_id: string;
rating: number;
comment: string | null;
tags?: string[];
}
/** Language-neutral thumbs-down reason slugs; must match the backend's
* VALID_FEEDBACK_TAGS (deerflow.domain.feedback.model). */
export const FEEDBACK_TAG_SLUGS = [
"incorrect",
"not_as_expected",
"slow",
"style_tone",
"safety_legal",
"other",
] as const;
export type FeedbackTagSlug = (typeof FEEDBACK_TAG_SLUGS)[number];
export async function upsertFeedback(
threadId: string,
runId: string,
rating: number,
comment?: string,
tags?: string[],
): Promise<FeedbackData> {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment: comment ?? null }),
body: JSON.stringify({
rating,
comment: comment ?? null,
tags: tags ?? [],
}),
},
);
if (!res.ok) {

View File

@ -18,6 +18,21 @@ export const enUS: Translations = {
},
// Common
feedback: {
thumbsUp: "Good response",
thumbsDown: "Bad response",
dialogTitle: "Share feedback",
detailsPlaceholder: "Share more details (optional)",
submit: "Submit",
tags: {
incorrect: "Incorrect or incomplete",
notAsExpected: "Not what I expected",
slow: "Slow or had issues",
styleTone: "Style or tone",
safetyLegal: "Safety or legal concern",
other: "Other",
},
},
common: {
home: "Home",
settings: "Settings",

View File

@ -7,6 +7,21 @@ export interface Translations {
};
// Common
feedback: {
thumbsUp: string;
thumbsDown: string;
dialogTitle: string;
detailsPlaceholder: string;
submit: string;
tags: {
incorrect: string;
notAsExpected: string;
slow: string;
styleTone: string;
safetyLegal: string;
other: string;
};
};
common: {
home: string;
settings: string;

View File

@ -18,6 +18,21 @@ export const zhCN: Translations = {
},
// Common
feedback: {
thumbsUp: "点赞",
thumbsDown: "点踩",
dialogTitle: "分享反馈",
detailsPlaceholder: "分享详细信息(可选)",
submit: "提交",
tags: {
incorrect: "不正确或不完整",
notAsExpected: "与期望不符",
slow: "速度慢或存在问题",
styleTone: "风格或语气",
safetyLegal: "安全或法律疑虑",
other: "其他",
},
},
common: {
home: "首页",
settings: "设置",