SarahXia0405's picture
Update web/src/components/Message.tsx
4d3c21a verified
// web/src/components/Message.tsx
import React, { useMemo, useState } from "react";
import { Button } from "./ui/button";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
Copy,
ThumbsUp,
ThumbsDown,
ChevronDown,
ChevronUp,
Check,
X,
} from "lucide-react";
import { Badge } from "./ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./ui/collapsible";
import { Textarea } from "./ui/textarea";
import type { Message as MessageType } from "../App";
import { toast } from "sonner";
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
// ✅ NEW: real API call
import { apiFeedback } from "../lib/api";
interface MessageProps {
key?: React.Key;
message: MessageType;
showSenderInfo?: boolean; // For group chat mode
isFirstGreeting?: boolean; // Indicates if this is the first greeting message
showNextButton?: boolean; // For quiz mode
onNextQuestion?: () => void; // For quiz mode
chatMode?: "ask" | "review" | "quiz"; // Current chat mode
// ✅ NEW: for feedback submission
currentUserId?: string;
docType?: string;
learningMode?: string;
}
// 反馈标签选项
const FEEDBACK_TAGS = {
"not-helpful": [
"Code was incorrect",
"Shouldn't have used Memory",
"Don't like the personality",
"Don't like the style",
"Not factually correct",
],
helpful: [
"Accurate and helpful",
"Clear explanation",
"Good examples",
"Solved my problem",
"Well structured",
],
};
// ---- Markdown list normalization ----
function normalizeMarkdownLists(input: string) {
if (!input) return input;
return (
input
.replace(/(^|\n)(\s*)(\d+\.)\s*\n+\s+/g, "$1$2$3 ")
.replace(/(^|\n)(\s*)([-*+])\s*\n+\s+/g, "$1$2$3 ")
);
}
export function Message({
message,
showSenderInfo = false,
isFirstGreeting = false,
showNextButton = false,
onNextQuestion,
chatMode = "ask",
// ✅ NEW
currentUserId,
docType = "Syllabus",
learningMode = "general",
}: MessageProps) {
const [feedback, setFeedback] = useState<"helpful" | "not-helpful" | null>(
null
);
const [copied, setCopied] = useState(false);
const [referencesOpen, setReferencesOpen] = useState(false);
const [showFeedbackArea, setShowFeedbackArea] = useState(false);
const [feedbackType, setFeedbackType] = useState<
"helpful" | "not-helpful" | null
>(null);
const [feedbackText, setFeedbackText] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [nextButtonClicked, setNextButtonClicked] = useState(false);
const isUser = message.role === "user";
const isWelcomeMessage =
isFirstGreeting || message.id === "review-1" || message.id === "quiz-1";
const shouldShowActions = isUser ? true : !isWelcomeMessage;
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
toast.success("Message copied to clipboard");
setTimeout(() => setCopied(false), 2000);
};
const handleFeedbackClick = (type: "helpful" | "not-helpful") => {
if (feedback === type) {
setFeedback(null);
setShowFeedbackArea(false);
setFeedbackType(null);
setFeedbackText("");
setSelectedTags([]);
} else {
setFeedback(type);
setFeedbackType(type);
setShowFeedbackArea(true);
}
};
const handleFeedbackClose = () => {
setShowFeedbackArea(false);
setFeedbackType(null);
setFeedbackText("");
setSelectedTags([]);
};
const handleTagToggle = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
// ✅ REAL submit to backend -> LangSmith dataset
const handleFeedbackSubmit = async () => {
if (!currentUserId || !currentUserId.trim()) {
toast.error("Missing user_id; cannot submit feedback.");
return;
}
if (!feedbackType) {
toast.error("Please select Helpful / Not helpful.");
return;
}
// UI uses "not-helpful" (dash), backend expects "not_helpful" (underscore)
const rating = feedbackType === "helpful" ? "helpful" : "not_helpful";
try {
await apiFeedback({
user_id: currentUserId,
rating,
assistant_message_id: message.id,
assistant_text: message.content,
user_text: "", // optional; you can wire last user msg later if you want
comment: feedbackText || "",
tags: selectedTags,
refs: message.references ?? [],
learning_mode: chatMode === "ask" ? learningMode : chatMode,
doc_type: docType,
timestamp_ms: Date.now(),
});
toast.success("Thanks — feedback submitted.");
handleFeedbackClose();
} catch (e: any) {
// eslint-disable-next-line no-console
console.error("feedback submit failed:", e);
toast.error(e?.message || "Failed to submit feedback.");
}
};
const normalizedMarkdown = useMemo(() => {
return normalizeMarkdownLists(message.content || "");
}, [message.content]);
const markdownClass = useMemo(() => {
return [
"text-base leading-relaxed break-words",
" [&_p]:my-2",
" [&_p:first-child]:mt-0",
" [&_p:last-child]:mb-0",
" [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-black/5 dark:[&_code]:bg-white/10",
" [&_pre]:my-2 [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-auto [&_pre]:bg-black/5 dark:[&_pre]:bg-white/10",
" [&_a]:underline [&_a]:underline-offset-2",
].join("");
}, []);
const renderBubbleContent = () => {
if (isUser) {
return (
<p className="whitespace-pre-wrap text-base break-words">
{message.content}
</p>
);
}
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
className={markdownClass}
components={{
p: ({ children }) => (
<p className="my-2 whitespace-pre-wrap break-words">{children}</p>
),
ul: ({ children }) => <ul className="my-3 pl-6 space-y-2">{children}</ul>,
ol: ({ children }) => <ol className="my-3 space-y-4">{children}</ol>,
li: ({ children, node }) => {
const parent = (node as any)?.parent?.tagName;
if (parent === "ol") {
return (
<li className="list-none">
<div className="flex items-start">
<span className="w-6 text-right pr-2 flex-shrink-0 font-medium">
{(node as any)?.index + 1}.
</span>
<div className="min-w-0">{children}</div>
</div>
</li>
);
}
return <li>{children}</li>;
},
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{normalizedMarkdown}
</ReactMarkdown>
);
};
return (
<div
className={`flex gap-2 ${
isUser && !showSenderInfo ? "justify-end" : "justify-start"
} px-4`}
>
{/* Avatar */}
{showSenderInfo && message.sender ? (
<div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 overflow-hidden bg-white">
{message.sender.isAI ? (
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
) : (
<img
src={
message.sender.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
message.sender.email || message.sender.name
)}`
}
alt={message.sender.name}
className="w-full h-full object-cover"
/>
)}
</div>
) : !isUser ? (
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
</div>
) : null}
<div
className={`group flex flex-col gap-2 ${
isUser && !showSenderInfo ? "items-end" : "items-start"
}`}
style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}
>
{/* Sender name in group chat */}
{showSenderInfo && message.sender && (
<div className="flex items-center gap-2 px-1">
<span className="text-xs">{message.sender.name}</span>
{message.sender.isAI && (
<Badge variant="secondary" className="text-xs h-4 px-1">
AI
</Badge>
)}
</div>
)}
{/* Bubble */}
<div
className={`
rounded-2xl px-4 py-3
${isUser && !showSenderInfo ? "bg-primary text-primary-foreground" : "bg-muted"}
`}
>
{renderBubbleContent()}
</div>
{/* Next Question Button for Quiz Mode */}
{!isUser &&
showNextButton &&
!nextButtonClicked &&
chatMode === "quiz" &&
onNextQuestion && (
<div className="mt-2">
<Button
onClick={() => {
setNextButtonClicked(true);
onNextQuestion();
}}
className="bg-primary hover:bg-primary/90"
>
Next Question
</Button>
</div>
)}
{/* References */}
{message.references && message.references.length > 0 && (
<Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 h-7 text-xs">
{referencesOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{message.references.length}{" "}
{message.references.length === 1 ? "reference" : "references"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 mt-1">
{message.references.map((ref, index) => (
<Badge key={index} variant="outline" className="text-xs">
{ref}
</Badge>
))}
</CollapsibleContent>
</Collapsible>
)}
{/* Message Actions */}
{shouldShowActions && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
copied
? "bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400"
: ""
}`}
onClick={handleCopy}
title="Copy"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
{!isUser && (
<>
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
feedback === "helpful"
? "bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400"
: ""
}`}
onClick={() => handleFeedbackClick("helpful")}
title="Helpful"
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
feedback === "not-helpful"
? "bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400"
: ""
}`}
onClick={() => handleFeedbackClick("not-helpful")}
title="Not helpful"
>
<ThumbsDown className="h-4 w-4" />
</Button>
</>
)}
</div>
)}
{/* Feedback Area */}
{!isUser && showFeedbackArea && feedbackType && (
<div className="w-full mt-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between mb-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Tell us more:
</h4>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleFeedbackClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{FEEDBACK_TAGS[feedbackType].map((tag) => (
<Button
key={tag}
variant={selectedTags.includes(tag) ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => handleTagToggle(tag)}
>
{tag}
</Button>
))}
</div>
<Textarea
className="min-h-[60px] mb-4 bg-gray-100/50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="Additional feedback (optional)..."
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleFeedbackClose}>
Cancel
</Button>
<Button
size="sm"
onClick={handleFeedbackSubmit}
disabled={selectedTags.length === 0 && !feedbackText.trim()}
>
Submit
</Button>
</div>
</div>
)}
</div>
{isUser && !showSenderInfo && (
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
{message.sender ? (
<img
src={
message.sender.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
message.sender.email || message.sender.name
)}`
}
alt={message.sender.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-base">👤</span>
)}
</div>
)}
</div>
);
}