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