SarahXia0405's picture
Update web/src/components/Message.tsx
ae2ad37 verified
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>
);
}