// 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([]); 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 (

{message.content}

); } return ( (

{children}

), ul: ({ children }) => , ol: ({ children }) =>
    {children}
, li: ({ children, node }) => { const parent = (node as any)?.parent?.tagName; if (parent === "ol") { return (
  • {(node as any)?.index + 1}.
    {children}
  • ); } return
  • {children}
  • ; }, strong: ({ children }) => {children}, em: ({ children }) => {children}, }} > {normalizedMarkdown}
    ); }; return (
    {/* Avatar */} {showSenderInfo && message.sender ? (
    {message.sender.isAI ? ( Clare ) : ( {message.sender.name} )}
    ) : !isUser ? (
    Clare
    ) : null}
    {/* Sender name in group chat */} {showSenderInfo && message.sender && (
    {message.sender.name} {message.sender.isAI && ( AI )}
    )} {/* Bubble */}
    {renderBubbleContent()}
    {/* Next Question Button for Quiz Mode */} {!isUser && showNextButton && !nextButtonClicked && chatMode === "quiz" && onNextQuestion && (
    )} {/* References */} {message.references && message.references.length > 0 && ( {message.references.map((ref, index) => ( {ref} ))} )} {/* Message Actions */} {shouldShowActions && (
    {!isUser && ( <> )}
    )} {/* Feedback Area */} {!isUser && showFeedbackArea && feedbackType && (

    Tell us more:

    {FEEDBACK_TAGS[feedbackType].map((tag) => ( ))}