mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 07:28:07 +00:00
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:
parent
c9e6aa830b
commit
86f93a18cc
113
frontend/src/components/workspace/messages/feedback-dialog.tsx
Normal file
113
frontend/src/components/workspace/messages/feedback-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "设置",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user