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