semblance/src/components/ThemeHighlighter.tsx
2025-08-04 09:07:59 -05:00

257 lines
11 KiB
TypeScript

import React from 'react';
import { Lightbulb, X, PencilIcon, Brain, MessageCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { HighlightedTheme, GeneratedTheme, Message, Theme, QuoteData } from '@/components/focus-group-session/types';
import { Persona } from '@/types/persona';
import { toast } from 'sonner';
interface ThemeHighlighterProps {
themes: Theme[];
messages: Message[];
personas?: Persona[];
onThemeDelete?: (themeId: string) => void;
focusGroupId?: string;
onQuoteClick?: (quote: string | QuoteData, messageId?: string) => void;
}
const ThemeHighlighter = ({
themes,
messages,
personas = [],
onThemeDelete,
onQuoteClick
}: ThemeHighlighterProps) => {
const handleThemeDelete = (e: React.MouseEvent, themeId: string) => {
e.stopPropagation();
if (onThemeDelete) {
onThemeDelete(themeId);
toast.success("Theme deleted successfully");
}
};
// Helper function to get persona name by ID
const getPersona = (id: string) => {
return personas.find(p => p.id === id || p._id === id);
};
// Helper function to parse attributed quotes
const parseAttributedQuote = (quote: string) => {
// First, check if quote has message ID format and strip it
let cleanQuote = quote;
const msgIdMatch = quote.match(/^\[MSG_ID:[^\]]+\]\s*(.*)$/);
if (msgIdMatch) {
cleanQuote = msgIdMatch[1];
}
// Check if quote has attribution format [Name]: text
const attributionMatch = cleanQuote.match(/^\[([^\]]+)\]:\s*(.*)$/);
if (attributionMatch) {
return {
persona: attributionMatch[1],
text: attributionMatch[2]
};
}
// Check if quote has simple attribution format Name: text (without brackets)
const simpleAttributionMatch = cleanQuote.match(/^([^:]+):\s*(.*)$/);
if (simpleAttributionMatch && simpleAttributionMatch[1].trim() !== cleanQuote.trim()) {
return {
persona: simpleAttributionMatch[1].trim(),
text: simpleAttributionMatch[2]
};
}
// Fallback for quotes without attribution
return {
persona: null,
text: cleanQuote
};
};
// Split themes into highlighted and generated
const highlightedThemes = themes.filter(theme =>
'source' in theme ? theme.source === 'highlight' : true // For backward compatibility
) as HighlightedTheme[];
const generatedThemes = themes.filter(theme =>
'source' in theme && theme.source === 'generated'
) as GeneratedTheme[];
return (
<div className="glass-panel rounded-xl p-6 h-[70vh] flex flex-col overflow-hidden">
<div className="flex items-center mb-4">
<Lightbulb className="h-5 w-5 text-primary mr-2" />
<h2 className="font-sf text-xl font-semibold">Key Themes</h2>
</div>
<div className="overflow-auto">
{generatedThemes.length > 0 && (
<div className="mb-8">
<div className="flex items-center mb-3">
<Brain className="h-4 w-4 text-primary mr-2" />
<h3 className="font-medium">AI-Generated Themes</h3>
</div>
<div className="grid grid-cols-1 gap-4 mb-4">
{generatedThemes.map((theme) => (
<Card
key={theme.id}
className="hover:shadow-md transition-shadow relative group"
>
{onThemeDelete && (
<button
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleThemeDelete(e, theme.id)}
>
<X className="h-3 w-3 text-slate-700" />
</button>
)}
<CardHeader className="pb-2">
<CardTitle className="text-base">{theme.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600 mb-2">{theme.description}</p>
{theme.quotes && theme.quotes.length > 0 && (
<div className="mt-3">
<h4 className="text-xs font-medium text-slate-700 mb-2">Supporting Quotes:</h4>
<div className="space-y-2">
{theme.quotes.map((quote, index) => {
// Handle both string and QuoteData formats
const isQuoteData = typeof quote === 'object' && quote !== null;
const quoteText = isQuoteData ? quote.text : quote;
const speaker = isQuoteData ? quote.speaker : parseAttributedQuote(quote).persona;
const messageId = isQuoteData ? quote.message_id : undefined;
const originalQuote = isQuoteData ? quote.original : quote;
return (
<div
key={index}
className="bg-slate-50 p-2 rounded text-xs text-slate-600 border-l-2 border-slate-200 cursor-pointer hover:bg-slate-100 transition-colors"
onClick={(e) => {
e.stopPropagation();
if (onQuoteClick) {
onQuoteClick(isQuoteData ? quote as QuoteData : originalQuote, messageId);
}
}}
title={messageId ? `Message ID: ${messageId}` : 'Click to find original message'}
>
{speaker && (
<span className="font-semibold text-slate-700 mr-1">
{speaker}:
</span>
)}
"{quoteText}"
{messageId && (
<span className="ml-2 text-xs text-green-600 opacity-70">
</span>
)}
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
{highlightedThemes.length > 0 && (
<div>
<div className="flex items-center mb-3">
<PencilIcon className="h-4 w-4 text-primary mr-2" />
<h3 className="font-medium">Highlighted Comments</h3>
</div>
<div className="grid grid-cols-1 gap-4 mb-4">
{highlightedThemes.map((theme) => {
// Find the associated message to get full text and speaker info
const associatedMessage = theme.messages.length > 0
? messages.find(msg => msg.id === theme.messages[0])
: null;
// Get full text (up to 200 characters) instead of truncated version
const fullText = associatedMessage?.text || theme.text;
const displayText = fullText.length > 200
? fullText.substring(0, 200) + '...'
: fullText;
// Get speaker information
const senderId = associatedMessage?.senderId;
let speakerName = '';
if (senderId === 'moderator') {
speakerName = 'AI Moderator';
} else if (senderId === 'facilitator') {
speakerName = 'Human Facilitator';
} else if (senderId) {
// Get the actual participant name from personas
const persona = getPersona(senderId);
speakerName = persona?.name || 'Unknown Participant';
}
return (
<Card
key={theme.id}
className="hover:shadow-md hover:bg-slate-50 transition-all cursor-pointer relative group"
onClick={(e) => {
e.stopPropagation();
if (onQuoteClick && associatedMessage) {
onQuoteClick(associatedMessage.text, associatedMessage.id);
}
}}
title="Click to view in discussion"
>
{onThemeDelete && (
<button
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity z-10"
onClick={(e) => handleThemeDelete(e, theme.id)}
>
<X className="h-3 w-3 text-slate-700" />
</button>
)}
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-800 line-clamp-2">
{speakerName && (
<span className="text-primary font-semibold">
{speakerName}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-slate-600 leading-relaxed">
"{displayText}"
</p>
<div className="mt-2 flex items-center text-xs text-slate-400">
<MessageCircle className="h-3 w-3 mr-1" />
Click to view in discussion
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{themes.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center bg-slate-50 rounded-lg">
<Lightbulb className="h-8 w-8 text-slate-400 mb-3" />
<p className="text-slate-600">No themes have been identified yet.</p>
<p className="text-sm text-slate-500 mt-2">
Highlight important messages in the discussion or generate themes automatically.
</p>
</div>
)}
</div>
</div>
);
};
export default ThemeHighlighter;