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"; | |
| 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 | |
| } | |
| // 反馈标签选项 | |
| 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 ---- | |
| // Fixes patterns like: | |
| // 1. | |
| // | |
| // **Title** | |
| // ... -> 1. **Title** | |
| function normalizeMarkdownLists(input: string) { | |
| if (!input) return input; | |
| return ( | |
| input | |
| // ordered list: "1.\n\n text" -> "1. text" | |
| .replace(/(^|\n)(\s*)(\d+\.)\s*\n+\s+/g, "$1$2$3 ") | |
| // unordered list: "-\n\n text" -> "- text" | |
| .replace(/(^|\n)(\s*)([-*+])\s*\n+\s+/g, "$1$2$3 ") | |
| ); | |
| } | |
| function firstTextish(children: React.ReactNode): string { | |
| const arr = React.Children.toArray(children); | |
| for (const ch of arr) { | |
| if (typeof ch === "string") return ch; | |
| if (React.isValidElement(ch)) { | |
| const inner = (ch.props as any)?.children; | |
| const t = firstTextish(inner); | |
| if (t) return t; | |
| } | |
| } | |
| return ""; | |
| } | |
| function startsWithEmojiBullet(text: string) { | |
| const t = (text || "").trim(); | |
| // 你图二里的风格:✅ / 🔧 等开头就不要再加圆点 | |
| return /^(✅|☑️|✔️|🟢|🔧|⭐️|✨|🔥|👉|➡️|•)/.test(t); | |
| } | |
| export function Message({ | |
| message, | |
| showSenderInfo = false, | |
| isFirstGreeting = false, | |
| showNextButton = false, | |
| onNextQuestion, | |
| chatMode = "ask", | |
| }: 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] | |
| ); | |
| }; | |
| const handleFeedbackSubmit = () => { | |
| const feedbackData = { | |
| type: feedbackType, | |
| tags: selectedTags, | |
| text: feedbackText, | |
| messageId: message.id || message.content.substring(0, 50), | |
| }; | |
| console.log("Feedback submitted:", feedbackData); | |
| toast.success("感谢您的反馈!"); | |
| handleFeedbackClose(); | |
| }; | |
| 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 / pre | |
| " [&_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", | |
| // links | |
| " [&_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> | |
| ), | |
| /* ---------- Unordered list (· bullet) ---------- */ | |
| ul: ({ children }) => ( | |
| <ul className="my-3 pl-6 space-y-2"> | |
| {children} | |
| </ul> | |
| ), | |
| /* ---------- Ordered list (1. 2. 3.) ---------- */ | |
| /* ---------- Ordered list (1. 2. 3.) ---------- */ | |
| ol: ({ children }) => ( | |
| <ol className="my-3 space-y-4"> | |
| {children} | |
| </ol> | |
| ), | |
| li: ({ children, node }) => { | |
| const parent = (node as any)?.parent?.tagName; | |
| // —— ordered list: number aligned left, text indented | |
| if (parent === "ol") { | |
| return ( | |
| <li className="list-none"> | |
| <div className="flex items-start"> | |
| {/* number column */} | |
| <span className="w-6 text-right pr-2 flex-shrink-0 font-medium"> | |
| {(node as any)?.index + 1}. | |
| </span> | |
| {/* content column */} | |
| <div className="min-w-0"> | |
| {children} | |
| </div> | |
| </div> | |
| </li> | |
| ); | |
| } | |
| /* fallback (unordered handled elsewhere) */ | |
| 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> | |
| ); | |
| } | |