Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { Button } from './ui/button'; | |
| import { | |
| Copy, | |
| ThumbsUp, | |
| ThumbsDown, | |
| ChevronDown, | |
| ChevronUp, | |
| Check, | |
| Bot | |
| } from 'lucide-react'; | |
| import { Badge } from './ui/badge'; | |
| import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; | |
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; | |
| import { Textarea } from './ui/textarea'; | |
| import type { Message as MessageType, LearningMode } from '../App'; | |
| import { toast } from 'sonner'; | |
| interface MessageProps { | |
| message: MessageType; | |
| showSenderInfo?: boolean; | |
| // ✅ needed for logging feedback | |
| userId: string; | |
| isLoggedIn: boolean; | |
| learningMode: LearningMode; | |
| docType: string; | |
| // optional: pass the last user message so feedback can include question | |
| lastUserText?: string; | |
| } | |
| async function postJson(path: string, payload: any) { | |
| const res = await fetch(path, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!res.ok) { | |
| const txt = await res.text().catch(() => ''); | |
| throw new Error(`HTTP ${res.status}: ${txt || res.statusText}`); | |
| } | |
| return res.json(); | |
| } | |
| export function Message({ | |
| message, | |
| showSenderInfo = false, | |
| userId, | |
| isLoggedIn, | |
| learningMode, | |
| docType, | |
| lastUserText = '', | |
| }: MessageProps) { | |
| const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null); | |
| const [copied, setCopied] = useState(false); | |
| const [referencesOpen, setReferencesOpen] = useState(false); | |
| const [showFeedbackDialog, setShowFeedbackDialog] = useState(false); | |
| const [feedbackType, setFeedbackType] = useState<'helpful' | 'not-helpful' | null>(null); | |
| const [feedbackText, setFeedbackText] = useState(''); | |
| const [submitting, setSubmitting] = useState(false); | |
| const isUser = message.role === 'user'; | |
| const refs = useMemo(() => (message.references || []).filter(Boolean), [message.references]); | |
| const handleCopy = async () => { | |
| await navigator.clipboard.writeText(message.content); | |
| setCopied(true); | |
| toast.success('Message copied to clipboard'); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const handleFeedbackDialogOpen = (type: 'helpful' | 'not-helpful') => { | |
| if (!isLoggedIn || !userId) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| setFeedbackType(type); | |
| setShowFeedbackDialog(true); | |
| }; | |
| const handleFeedbackDialogClose = () => { | |
| setShowFeedbackDialog(false); | |
| setFeedbackType(null); | |
| setFeedbackText(''); | |
| }; | |
| const handleFeedbackDialogSubmit = async () => { | |
| if (!feedbackType) return; | |
| if (!isLoggedIn || !userId) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| try { | |
| setSubmitting(true); | |
| await postJson('/api/feedback', { | |
| user_id: userId, | |
| rating: feedbackType === 'helpful' ? 'helpful' : 'not_helpful', | |
| assistant_message_id: message.id, | |
| assistant_text: message.content, | |
| user_text: lastUserText || '', | |
| comment: feedbackText.trim(), | |
| refs, | |
| learning_mode: learningMode, | |
| doc_type: docType, | |
| timestamp_ms: Date.now(), | |
| }); | |
| setFeedback(feedbackType); | |
| toast.success('Thanks for your feedback!'); | |
| handleFeedbackDialogClose(); | |
| } catch (e: any) { | |
| console.error(e); | |
| toast.error(`Feedback failed: ${e?.message || 'unknown error'}`); | |
| } 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 ? 'bg-gradient-to-br from-purple-500 to-blue-500' : 'bg-muted' | |
| }`} | |
| > | |
| {message.sender.isAI ? ( | |
| <Bot className="h-4 w-4 text-white" /> | |
| ) : ( | |
| <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 bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0"> | |
| <span className="text-white text-sm">C</span> | |
| </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'} | |
| `} | |
| > | |
| <p className="whitespace-pre-wrap">{message.content}</p> | |
| </div> | |
| {/* References */} | |
| {refs.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" />} | |
| {refs.length} {refs.length === 1 ? 'reference' : 'references'} | |
| </Button> | |
| </CollapsibleTrigger> | |
| <CollapsibleContent className="space-y-1 mt-1"> | |
| {refs.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' ? 'text-green-600' : ''}`} | |
| onClick={() => handleFeedbackDialogOpen('helpful')} | |
| disabled={!isLoggedIn} | |
| > | |
| <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' ? 'text-red-600' : ''}`} | |
| onClick={() => handleFeedbackDialogOpen('not-helpful')} | |
| disabled={!isLoggedIn} | |
| > | |
| <ThumbsDown className="h-3 w-3" /> | |
| <span className="text-xs">Not helpful</span> | |
| </Button> | |
| </> | |
| )} | |
| </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> | |
| )} | |
| {/* Feedback Dialog */} | |
| <Dialog open={showFeedbackDialog} onOpenChange={handleFeedbackDialogClose}> | |
| <DialogContent className="sm:max-w-[425px]"> | |
| <DialogHeader> | |
| <DialogTitle>Provide Feedback</DialogTitle> | |
| <DialogDescription> | |
| {feedbackType === 'helpful' | |
| ? 'Tell us why you found this message helpful.' | |
| : 'Tell us why you found this message not helpful.'} | |
| </DialogDescription> | |
| </DialogHeader> | |
| <Textarea | |
| className="h-20" | |
| value={feedbackText} | |
| onChange={(e) => setFeedbackText(e.target.value)} | |
| placeholder="Type your feedback here..." | |
| /> | |
| <DialogFooter> | |
| <Button type="button" variant="outline" onClick={handleFeedbackDialogClose} disabled={submitting}> | |
| Cancel | |
| </Button> | |
| <Button type="button" onClick={handleFeedbackDialogSubmit} disabled={submitting}> | |
| {submitting ? 'Submitting...' : 'Submit'} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |