Spaces:
Sleeping
Sleeping
| // 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> | |
| ); | |
| } | |