Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react'; | |
| import { Button } from './ui/button'; | |
| 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'; | |
| // ✅ Markdown rendering (NEW) | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| // ✅ NEW: call backend feedback API | |
| import { | |
| apiFeedback, | |
| type User as ApiUser, | |
| type LearningMode as ApiLearningMode, | |
| type FileType as ApiFileType, | |
| type FeedbackRating, | |
| } from '../lib/api'; | |
| interface MessageProps { | |
| message: MessageType; | |
| showSenderInfo?: boolean; // For group chat mode | |
| // ✅ NEW (recommended) — for logging feedback correctly | |
| user?: ApiUser | null; | |
| // context (optional but useful for metadata) | |
| learningMode?: ApiLearningMode; | |
| docType?: ApiFileType | string; | |
| // optional: supply refs (if your message.references is not the same as backend refs) | |
| refs?: string[]; | |
| /** | |
| * Optional: provide the user question that led to this assistant message. | |
| * Best practice: parent passes a function that finds previous user message. | |
| */ | |
| getContextUserText?: () => string; | |
| } | |
| // 反馈标签选项 | |
| const FEEDBACK_TAGS: Record<FeedbackRating, string[]> = { | |
| 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', | |
| ], | |
| }; | |
| export function Message({ | |
| message, | |
| showSenderInfo = false, | |
| // NEW | |
| user = null, | |
| learningMode, | |
| docType, | |
| refs, | |
| getContextUserText, | |
| }: MessageProps) { | |
| const [feedback, setFeedback] = useState<FeedbackRating | null>(null); | |
| const [copied, setCopied] = useState(false); | |
| const [referencesOpen, setReferencesOpen] = useState(false); | |
| const [showFeedbackArea, setShowFeedbackArea] = useState(false); | |
| // ✅ unify to backend enum | |
| const [feedbackType, setFeedbackType] = useState<FeedbackRating | null>(null); | |
| const [feedbackText, setFeedbackText] = useState(''); | |
| const [selectedTags, setSelectedTags] = useState<string[]>([]); | |
| const [submitting, setSubmitting] = useState(false); | |
| const isUser = message.role === 'user'; | |
| const handleCopy = async () => { | |
| await navigator.clipboard.writeText(message.content); | |
| setCopied(true); | |
| toast.success('Message copied to clipboard'); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const handleFeedbackClick = (type: FeedbackRating) => { | |
| if (feedback === type) { | |
| // clicked same state -> collapse + reset detail | |
| setFeedback(null); | |
| setShowFeedbackArea(false); | |
| setFeedbackType(null); | |
| setFeedbackText(''); | |
| setSelectedTags([]); | |
| return; | |
| } | |
| // open feedback area | |
| setFeedback(type); | |
| setFeedbackType(type); | |
| setShowFeedbackArea(true); | |
| }; | |
| const handleFeedbackClose = () => { | |
| setShowFeedbackArea(false); | |
| setFeedbackType(null); | |
| setFeedbackText(''); | |
| setSelectedTags([]); | |
| // do NOT reset feedback (keeps thumbs state) | |
| }; | |
| const handleTagToggle = (tag: string) => { | |
| setSelectedTags((prev) => | |
| prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], | |
| ); | |
| }; | |
| const handleFeedbackSubmit = async () => { | |
| if (!feedbackType) return; | |
| // If not logged in / no user, we cannot attribute feedback to a student_id | |
| if (!user?.email) { | |
| toast.error('Please login to submit feedback.'); | |
| return; | |
| } | |
| const assistantText = (message.content || '').trim(); | |
| const userText = (getContextUserText ? getContextUserText() : '').trim(); | |
| // refs: prefer explicit refs prop; fallback to message.references | |
| const refsToSend = | |
| refs && refs.length > 0 ? refs : (message.references ?? []); | |
| setSubmitting(true); | |
| try { | |
| await apiFeedback({ | |
| user, | |
| rating: feedbackType, | |
| assistantMessageId: message.id, // recommended | |
| assistantText, | |
| userText, | |
| tags: selectedTags, | |
| comment: feedbackText, | |
| refs: refsToSend, | |
| learningMode, | |
| docType, | |
| timestampMs: Date.now(), | |
| }); | |
| toast.success('Thanks — feedback recorded.'); | |
| handleFeedbackClose(); | |
| // keep thumbs state | |
| } catch (e: any) { | |
| console.error('[feedback] submit failed:', e); | |
| toast.error(e?.message ? `Feedback failed: ${e.message}` : 'Feedback failed.'); | |
| } finally { | |
| setSubmitting(false); | |
| } | |
| }; | |
| return ( | |
| <div className={`flex gap-3 ${isUser && !showSenderInfo ? 'justify-end' : 'justify-start'}`}> | |
| {/* Avatar */} | |
| {showSenderInfo && message.sender ? ( | |
| <div | |
| className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${ | |
| message.sender.isAI ? 'overflow-hidden bg-white' : 'bg-muted' | |
| }`} | |
| > | |
| {message.sender.isAI ? ( | |
| <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" /> | |
| ) : ( | |
| <span className="text-sm"> | |
| {message.sender.name | |
| .split(' ') | |
| .map((n) => n[0]) | |
| .join('') | |
| .toUpperCase()} | |
| </span> | |
| )} | |
| </div> | |
| ) : !isUser ? ( | |
| <div className="w-8 h-8 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={`flex flex-col gap-2 max-w-[80%] ${isUser && !showSenderInfo ? 'items-end' : 'items-start'}`}> | |
| {/* 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> | |
| )} | |
| <div | |
| className={` | |
| rounded-2xl px-4 py-3 | |
| ${isUser && !showSenderInfo ? 'bg-primary text-primary-foreground' : 'bg-muted'} | |
| `} | |
| > | |
| {/* ✅ KEY FIX: assistant uses Markdown renderer */} | |
| {isUser ? ( | |
| <p className="whitespace-pre-wrap">{message.content}</p> | |
| ) : ( | |
| <div className="prose prose-sm max-w-none dark:prose-invert"> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={{ | |
| // keep line breaks looking natural in chat bubbles | |
| p: ({ children }) => <p className="whitespace-pre-wrap">{children}</p>, | |
| // code blocks + inline code styling | |
| code: ({ className, children }) => ( | |
| <code className={className ? className : 'px-1 py-0.5 rounded bg-black/5 dark:bg-white/10'}> | |
| {children} | |
| </code> | |
| ), | |
| }} | |
| > | |
| {message.content} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| </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 */} | |
| <div className="flex items-center gap-1"> | |
| <Button variant="ghost" size="sm" className="h-7 gap-1" onClick={handleCopy}> | |
| {copied ? ( | |
| <> | |
| <Check className="h-3 w-3" /> | |
| <span className="text-xs">Copied</span> | |
| </> | |
| ) : ( | |
| <> | |
| <Copy className="h-3 w-3" /> | |
| <span className="text-xs">Copy</span> | |
| </> | |
| )} | |
| </Button> | |
| {!isUser && ( | |
| <> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className={`h-7 gap-1 ${ | |
| feedback === 'helpful' ? 'bg-green-100 text-green-600 dark:bg-green-900/20' : '' | |
| }`} | |
| onClick={() => handleFeedbackClick('helpful')} | |
| > | |
| <ThumbsUp className="h-3 w-3" /> | |
| <span className="text-xs">Helpful</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className={`h-7 gap-1 ${ | |
| feedback === 'not_helpful' ? 'bg-red-100 text-red-600 dark:bg-red-900/20' : '' | |
| }`} | |
| onClick={() => handleFeedbackClick('not_helpful')} | |
| > | |
| <ThumbsDown className="h-3 w-3" /> | |
| <span className="text-xs">Not helpful</span> | |
| </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> | |
| {/* Tags */} | |
| <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> | |
| {/* Comment box */} | |
| <Textarea | |
| className="min-h-[60px] mb-4 bg-white dark:bg-gray-900" | |
| value={feedbackText} | |
| onChange={(e) => setFeedbackText(e.target.value)} | |
| placeholder="Additional feedback (optional)..." | |
| /> | |
| {/* Submit */} | |
| <div className="flex justify-end gap-2"> | |
| <Button variant="outline" size="sm" onClick={handleFeedbackClose} disabled={submitting}> | |
| Cancel | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={handleFeedbackSubmit} | |
| disabled={submitting || (selectedTags.length === 0 && !feedbackText.trim())} | |
| > | |
| {submitting ? 'Submitting…' : 'Submit'} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {isUser && !showSenderInfo && ( | |
| <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0"> | |
| <span className="text-sm">👤</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |