chore(release): sync frontend from monorepo v4
This commit is contained in:
164
src/components/AIFeedbackButtons.tsx
Normal file
164
src/components/AIFeedbackButtons.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackSummary,
|
||||
useSubmitAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AIFeedbackRating,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
export interface AIFeedbackButtonsProps {
|
||||
subjectType: AIFeedbackSubjectType;
|
||||
subjectId: number;
|
||||
promptKey?: string;
|
||||
promptVersion?: number;
|
||||
aiLogId?: number;
|
||||
entityId?: number;
|
||||
courseId?: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thumbs up/down widget for any AI-generated artefact.
|
||||
*
|
||||
* Always renders the same UI regardless of whether the user has rated the
|
||||
* artefact before — a previous rating will show as highlighted. Clicking the
|
||||
* same button again is a no-op; clicking the opposite button updates the
|
||||
* server-side row in place. A thumbs-down always prompts the user for a short
|
||||
* comment (required by the backend).
|
||||
*/
|
||||
export function AIFeedbackButtons({
|
||||
subjectType,
|
||||
subjectId,
|
||||
promptKey,
|
||||
promptVersion,
|
||||
aiLogId,
|
||||
entityId,
|
||||
courseId,
|
||||
size = "sm",
|
||||
className,
|
||||
}: AIFeedbackButtonsProps) {
|
||||
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
||||
const submit = useSubmitAIFeedback();
|
||||
|
||||
const [downOpen, setDownOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (downOpen) {
|
||||
setComment(summary.data?.my_comment ?? "");
|
||||
}
|
||||
}, [downOpen, summary.data?.my_comment]);
|
||||
|
||||
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
||||
|
||||
const submitUp = async () => {
|
||||
if (myRating === "up") return;
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "up",
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const submitDown = async () => {
|
||||
if (!comment.trim()) {
|
||||
toast.error("Please tell us what was wrong.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "down",
|
||||
comment: comment.trim(),
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
setDownOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const btnSize = size === "sm" ? "sm" : "default";
|
||||
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant={myRating === "up" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={submitUp}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs up"
|
||||
>
|
||||
<ThumbsUp className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={myRating === "down" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={() => setDownOpen(true)}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs down"
|
||||
>
|
||||
<ThumbsDown className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
description="Tell us what was wrong so we can improve this AI output."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitDown} disabled={submit.isPending}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIFeedbackButtons;
|
||||
Reference in New Issue
Block a user