semblance_backup/src/components/ChatMessage.tsx

188 lines
7.2 KiB
TypeScript

import { useState, useEffect } from 'react';
import { MessageSquare, UserCircle, Bot, Star, User, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Persona } from '@/types/persona';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
import { parseMentions, formatMentionsForDisplay } from '@/utils/mentionUtils';
import { focusGroupsApi } from '@/lib/api';
import { Message } from '@/components/focus-group-session/types';
interface ChatMessageProps {
message: Message;
persona: Persona | null;
toggleHighlight: () => void;
participants?: Persona[]; // For parsing @mentions in message text
focusGroupId?: string; // For loading creative assets
}
const ChatMessage = ({ message, persona, toggleHighlight, participants = [], focusGroupId }: ChatMessageProps) => {
const [isHovered, setIsHovered] = useState(false);
const isModerator = message.senderId === 'moderator';
const isFacilitator = message.senderId === 'facilitator';
// Parse and format mentions in the message text
const parsedMentions = parseMentions(message.text, participants);
const formattedText = formatMentionsForDisplay(message.text, parsedMentions.mentions);
// Check for visual asset using metadata (new system) or fallback to legacy parsing
const hasCreativeAsset = (isModerator || isFacilitator) &&
(message.visualAsset || extractLegacyAssetFilename(message.text)) &&
focusGroupId;
// Get asset info from metadata or fallback to legacy extraction
const getAssetInfo = () => {
if (message.visualAsset) {
// New metadata-driven approach
return {
filename: message.visualAsset.filename,
displayReference: message.visualAsset.displayReference
};
} else {
// Legacy fallback for existing messages
const legacyFilename = extractLegacyAssetFilename(message.text);
return legacyFilename ? {
filename: legacyFilename,
displayReference: legacyFilename
} : null;
}
};
const assetInfo = getAssetInfo();
// Legacy filename extraction for backward compatibility
function extractLegacyAssetFilename(text: string): string | null {
const filenamePatterns = [
/titled\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "titled 'filename.jpg'"
/asset\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "asset 'filename.jpg'"
/(fg-[a-f0-9]+-[a-f0-9]{32}\.(jpg|jpeg|png))/i, // fg-{id}-{uuid}.{ext}
];
for (const pattern of filenamePatterns) {
const match = text.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
const handleToggleHighlight = () => {
toggleHighlight();
};
return (
<div
id={`message-${message.id}`}
className={cn(
"flex items-start p-3 rounded-lg transition-colors",
message.highlighted ? "bg-amber-50 border border-amber-200" : "hover:bg-slate-50",
isModerator ? "border-l-4 border-l-primary pl-4" : "",
isFacilitator ? "border-l-4 border-l-green-500 pl-4" : ""
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-highlighted={message.highlighted ? "true" : "false"}
>
<div className="flex-shrink-0 mr-3">
{isModerator ? (
<div className="bg-primary/10 p-2 rounded-full">
<Bot className="h-6 w-6 text-primary" />
</div>
) : isFacilitator ? (
<div className="bg-green-100 p-2 rounded-full">
<User className="h-6 w-6 text-green-600" />
</div>
) : persona ? (
<div className="bg-slate-100 p-2 rounded-full">
<img
src={getPersonaAvatarSrc(persona)}
alt={`${persona.name} avatar`}
className="h-6 w-6 rounded-full object-cover"
/>
</div>
) : (
<div className="bg-slate-100 p-2 rounded-full">
<UserCircle className="h-6 w-6 text-slate-600" />
</div>
)}
</div>
<div className="flex-1">
<div className="flex items-center mb-1">
<span className="font-medium mr-2">
{isModerator ? 'AI Moderator' : isFacilitator ? 'Human Facilitator' : persona?.name || 'Unknown'}
</span>
{!isModerator && !isFacilitator && persona && (
<Badge variant="outline" className="text-xs font-normal">
{persona.occupation}
</Badge>
)}
<span className="text-xs text-slate-500 ml-auto">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="text-slate-700">
{!message.text || message.text.trim() === '' || message.text === '...' ? (
<span className="text-red-500 italic">
[No response content - AI generation may have failed]
</span>
) : (
formattedText
)}
</p>
{/* Display creative asset if this is a moderator/facilitator message with an asset */}
{hasCreativeAsset && assetInfo && (
<div className="mt-3 p-3 border rounded-lg bg-slate-50">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Creative Asset</span>
{assetInfo.displayReference !== assetInfo.filename && (
<span className="text-xs text-slate-500">({assetInfo.displayReference})</span>
)}
</div>
<img
src={focusGroupsApi.getAssetUrl(focusGroupId!, assetInfo.filename)}
alt="Creative asset for review"
className="max-w-full h-auto rounded border shadow-sm"
style={{ maxHeight: '300px' }}
onError={(e) => {
console.error('Failed to load creative asset:', focusGroupsApi.getAssetUrl(focusGroupId!, assetInfo.filename));
e.currentTarget.style.display = 'none';
// Show placeholder on error
const placeholder = document.createElement('div');
placeholder.className = 'text-xs text-slate-500 italic p-2 border rounded bg-slate-100';
placeholder.textContent = `Creative asset not found: ${assetInfo.displayReference}`;
e.currentTarget.parentNode?.appendChild(placeholder);
}}
/>
</div>
)}
<div className={cn("flex mt-2 space-x-2", !isHovered && !message.highlighted && "hidden")}>
<Button
variant="ghost"
size="sm"
onClick={handleToggleHighlight}
className="h-8 px-2 text-xs"
>
<Star className={cn(
"h-3 w-3 mr-1",
message.highlighted ? "fill-amber-400 text-amber-400" : "text-slate-400"
)} />
{message.highlighted ? 'Highlighted' : 'Highlight'}
</Button>
</div>
</div>
</div>
);
};
export default ChatMessage;