ALwrity HALLUCINATION DETECTOR AND ASSISTIVE WRITING

This commit is contained in:
ajaysi
2025-09-08 21:14:27 +05:30
parent 5ba19c097a
commit 6fd9a4e354
51 changed files with 8224 additions and 1086 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import { CopilotSidebar } from '@copilotkit/react-ui';
import { useCopilotReadable, useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
import '@copilotkit/react-ui/styles.css';
@@ -13,7 +13,8 @@ import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/Pe
const useCopilotActionTyped = useCopilotAction as any;
// Optional debug flag: set to true to enable verbose logs locally
const DEBUG_LINKEDIN = false;
interface LinkedInWriterProps {
className?: string;
@@ -299,15 +300,15 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
}
});
// Intelligent, stage-aware suggestions
const getIntelligentSuggestions = () => {
// Intelligent, stage-aware suggestions (memoized to prevent infinite re-rendering)
const getIntelligentSuggestions = useMemo(() => {
const hasContent = draft && draft.trim().length > 0;
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
const isLong = (draft || '').length > 500;
// Debug logging for suggestions
console.log('[LinkedIn Writer] Generating suggestions:', {
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
hasContent,
justGeneratedContent,
draftLength: draft?.length || 0
@@ -365,7 +366,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
// Add image generation suggestion when there's content
if (draft && draft.trim().length > 0) {
console.log('[LinkedIn Writer] Adding image generation suggestion');
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
// Make image generation suggestion more prominent
refinementSuggestions.push({
title: '🖼️ Generate Post Image',
@@ -386,10 +387,10 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
}
}
console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
return refinementSuggestions;
}
};
}, [draft, justGeneratedContent]);
return (
<div className={`linkedin-writer ${className}`} style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
@@ -398,94 +399,11 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
userPreferences={userPreferences}
chatHistory={chatHistory}
showPreferencesModal={showPreferencesModal}
showContextModal={showContextModal}
context={context}
onPreferencesModalChange={setShowPreferencesModal}
onContextModalChange={setShowContextModal}
onContextChange={handleContextChange}
onPreferencesChange={handlePreferencesChange}
onCopy={handleCopy}
onClear={handleClear}
onClearHistory={handleClearHistory}
draft={draft}
getHistoryLength={getHistoryLength}
/>
{/* Persona Integration Indicator */}
{corePersona && !personaLoading && (
<div
style={{
padding: '8px 16px',
backgroundColor: '#f0f8ff',
borderBottom: '1px solid #e1e8ed',
fontSize: '12px',
color: '#666',
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'help',
position: 'relative'
}}
title={`🎭 YOUR PERSONALIZED WRITING ASSISTANT
🤔 WHAT IS A PERSONA?
A persona is your unique writing style profile that AI uses to create content that sounds exactly like you. It's like having a digital twin of your writing voice!
🎯 HOW DOES IT HELP YOU?
✅ Generates content that matches your natural writing style
✅ Maintains consistent voice across all your LinkedIn posts
✅ Saves time by understanding your preferences automatically
✅ Optimizes content for LinkedIn's algorithm and your audience
✅ Provides personalized suggestions based on your industry
🧠 HOW WAS IT CREATED?
Your persona was built by analyzing:
• Your website content and writing patterns
• Your research preferences and content goals
• Your target audience and industry focus
• Your communication style and tone preferences
• LinkedIn-specific optimization requirements
🤖 HOW DOES COPILOTKIT USE IT?
The AI assistant now knows:
• Your preferred sentence length and structure
• Your go-to words and phrases to use/avoid
• Your professional tone and communication style
• LinkedIn-specific optimization strategies
• Your engagement patterns and posting preferences
🚀 HYPER-PERSONALIZATION ACHIEVED!
Instead of generic content, you get:
• Content that sounds authentically like you
• Industry-specific insights and terminology
• LinkedIn algorithm-optimized posts
• Professional networking strategies
• Personalized engagement tactics
📊 YOUR PERSONA DETAILS:
🎭 Name: ${corePersona.persona_name}
📋 Style: ${corePersona.archetype}
💭 Philosophy: ${corePersona.core_belief}
📈 Confidence: ${corePersona.confidence_score}% accuracy
🎯 LINKEDIN OPTIMIZATION:
• Optimal length: ${platformPersona?.content_format_rules?.optimal_length || '150-300 words'}
• Posting frequency: ${platformPersona?.engagement_patterns?.posting_frequency || '2-3 times per week'}
• Hashtag strategy: ${platformPersona?.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'}
• Engagement style: ${platformPersona?.engagement_patterns?.interaction_style || 'conversational'}
💡 TRY THIS: Ask the AI to "generate a LinkedIn post about [your topic]" and watch how it automatically applies your persona to create content that sounds like you!`}
>
<span style={{ color: '#0073b1' }}>🎭</span>
<span><strong>🎭 Your Writing Assistant:</strong> {corePersona.persona_name} ({corePersona.archetype})</span>
<span style={{ marginLeft: 'auto', fontSize: '11px' }}>
{corePersona.confidence_score}% accuracy |
Platform: LinkedIn Optimized
</span>
<span style={{ fontSize: '10px', color: '#999', marginLeft: '8px' }}>
(Hover for details)
</span>
</div>
)}
{/* Lightweight progress tracker under header */}
<div style={{
@@ -533,6 +451,7 @@ Instead of generic content, you get:
onDiscardChanges={handleDiscardChanges}
onDraftChange={handleDraftChange}
onPreviewToggle={handlePreviewToggle}
topic={context ? context.split('\n')[0].substring(0, 50) : undefined}
/>
@@ -560,7 +479,7 @@ Instead of generic content, you get:
'Great! I can see you have content to work with. Use the quick edit suggestions below to refine your post in real-time, or ask me to make specific changes.' :
`Hi! I'm your ALwrity Co-Pilot, your LinkedIn writing assistant${corePersona ? ` with ${corePersona.persona_name} persona optimization` : ''}. I can help you create professional posts, articles, carousels, video scripts, and comment responses. Try the new persona-aware actions for enhanced content generation!`
}}
suggestions={getIntelligentSuggestions()}
suggestions={getIntelligentSuggestions}
makeSystemMessage={(context: string, additional?: string) => {
const prefs = userPreferences;
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';

View File

@@ -117,6 +117,15 @@ const RegisterLinkedInActions: React.FC = () => {
],
handler: async (args: any) => {
const prefs = readPrefs();
// Start loading state
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
detail: {
action: 'generateLinkedInPost',
message: 'Generating LinkedIn post with persona optimization...'
}
}));
// Emit progress init
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
steps: [
@@ -251,6 +260,10 @@ const RegisterLinkedInActions: React.FC = () => {
}
}));
// Debug: Log the content being sent
console.log('[LinkedIn Writer] Sending draft update:', fullContent?.substring(0, 100) + '...');
console.log('[LinkedIn Writer] Full content length:', fullContent?.length);
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: fullContent }));
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
@@ -263,6 +276,10 @@ const RegisterLinkedInActions: React.FC = () => {
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
// End loading state
console.log('[LinkedIn Writer] Ending loading state...');
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
// Return recommendations message that CopilotKit can render
const recommendations = res.data?.quality_metrics?.recommendations || [];
if (recommendations.length > 0) {
@@ -284,6 +301,8 @@ const RegisterLinkedInActions: React.FC = () => {
};
}
}
// End loading state on error
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
return { success: false, message: res.error || 'Failed to generate LinkedIn post' };
}
@@ -301,6 +320,15 @@ const RegisterLinkedInActions: React.FC = () => {
],
handler: async (args: any) => {
const prefs = readPrefs();
// Start loading state
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
detail: {
action: 'generateLinkedInArticle',
message: 'Generating LinkedIn article with persona optimization...'
}
}));
// Emit progress init for article
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
steps: [
@@ -429,6 +457,9 @@ const RegisterLinkedInActions: React.FC = () => {
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
// End loading state
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
// Return recommendations message that CopilotKit can render
const recommendations = res.data?.quality_metrics?.recommendations || [];
if (recommendations.length > 0) {
@@ -450,6 +481,8 @@ const RegisterLinkedInActions: React.FC = () => {
};
}
}
// End loading state on error
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
return { success: false, message: res.error || 'Failed to generate LinkedIn article' };
}

View File

@@ -58,6 +58,14 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
// Persona-aware progress tracking
const personaInfo = corePersona ? `using ${corePersona.persona_name} persona` : 'with standard settings';
// Start loading state for chat-triggered flow as well
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
detail: {
action: 'generateLinkedInPostWithPersona',
message: 'Generating LinkedIn post with persona optimization...'
}
}));
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
steps: [
{ id: 'persona_analysis', label: `Analyzing ${personaInfo}` },
@@ -143,6 +151,13 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
});
}
// Append hashtags and CTA if present
const hashtags = res.data.hashtags?.map((h: any) => h.hashtag).join(' ') || '';
const cta = res.data.call_to_action || '';
let fullContent = enhancedContent;
if (hashtags) fullContent += `\n\n${hashtags}`;
if (cta) fullContent += `\n\n${cta}`;
// Update progress with persona validation
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
detail: {
@@ -217,10 +232,28 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
}
}));
// Update grounding data so citations and quality chips render
window.dispatchEvent(new CustomEvent('linkedinwriter:updateGroundingData', {
detail: {
researchSources: res.research_sources || [],
citations: res.data?.citations || [],
qualityMetrics: res.data?.quality_metrics || null,
groundingEnabled: res.data?.grounding_enabled || false,
searchQueries: res.data?.search_queries || []
}
}));
// Send draft content to editor
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: fullContent }));
// Complete progress and end loading
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
// Return enhanced content with persona information
return {
success: true,
content: enhancedContent,
content: fullContent,
persona_applied: corePersona ? {
name: corePersona.persona_name,
archetype: corePersona.archetype,
@@ -238,6 +271,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
};
} else {
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd', { detail: { error: res.error } }));
return { success: false, message: res.error || 'Failed to generate LinkedIn post' };
}
}

View File

@@ -0,0 +1,397 @@
import React from 'react';
import { Box, Typography, Chip, Button, Collapse, Link } from '@mui/material';
import { ExpandMore, ExpandLess, CheckCircle, Cancel, Help } from '@mui/icons-material';
interface SourceDocument {
title: string;
url: string;
text: string;
published_date?: string;
author?: string;
score: number;
}
interface Claim {
text: string;
confidence: number;
assessment: 'supported' | 'refuted' | 'insufficient_information';
supporting_sources: SourceDocument[];
refuting_sources: SourceDocument[];
reasoning?: string;
}
interface FactCheckResultsProps {
results: {
success: boolean;
claims: Claim[];
overall_confidence: number;
total_claims: number;
supported_claims: number;
refuted_claims: number;
insufficient_claims: number;
timestamp: string;
processing_time_ms?: number;
error?: string;
};
onClose: () => void;
}
const FactCheckResults: React.FC<FactCheckResultsProps> = ({ results, onClose }) => {
const [expandedClaims, setExpandedClaims] = React.useState<Set<number>>(new Set());
const toggleClaimExpansion = (index: number) => {
const newExpanded = new Set(expandedClaims);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedClaims(newExpanded);
};
const getAssessmentIcon = (assessment: string) => {
switch (assessment) {
case 'supported':
return <CheckCircle sx={{ color: '#4caf50', fontSize: 20 }} />;
case 'refuted':
return <Cancel sx={{ color: '#f44336', fontSize: 20 }} />;
default:
return <Help sx={{ color: '#ff9800', fontSize: 20 }} />;
}
};
const getAssessmentColor = (assessment: string) => {
switch (assessment) {
case 'supported':
return '#4caf50';
case 'refuted':
return '#f44336';
default:
return '#ff9800';
}
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.8) return '#4caf50';
if (confidence >= 0.6) return '#ff9800';
return '#f44336';
};
if (!results.success) {
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
}}
>
<Box
sx={{
backgroundColor: 'white',
borderRadius: 2,
padding: 3,
maxWidth: 500,
width: '90%',
maxHeight: '80vh',
overflow: 'auto'
}}
>
<Typography variant="h6" color="error" gutterBottom>
Fact-Checking Failed
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{results.error || 'An error occurred while checking facts. Please try again.'}
</Typography>
<Button variant="contained" onClick={onClose} fullWidth>
Close
</Button>
</Box>
</Box>
);
}
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
}}
>
<Box
sx={{
backgroundColor: 'white',
borderRadius: 2,
padding: 3,
maxWidth: 800,
width: '90%',
maxHeight: '80vh',
overflow: 'auto'
}}
>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5" component="h2">
Fact-Check Results
</Typography>
<Button onClick={onClose} variant="outlined">
Close
</Button>
</Box>
{/* Summary */}
<Box sx={{ mb: 3, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom>
Fact-Check Summary
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={`Overall Confidence: ${Math.round(results.overall_confidence * 100)}%`}
color={results.overall_confidence >= 0.8 ? 'success' : results.overall_confidence >= 0.6 ? 'warning' : 'error'}
variant="outlined"
/>
<Chip
label={`Total Claims: ${results.total_claims}`}
color="primary"
variant="outlined"
/>
<Chip
label={`Supported: ${results.supported_claims}`}
color="success"
variant="outlined"
/>
<Chip
label={`Refuted: ${results.refuted_claims}`}
color="error"
variant="outlined"
/>
<Chip
label={`Insufficient: ${results.insufficient_claims}`}
color="warning"
variant="outlined"
/>
</Box>
{/* Key Insights */}
<Box sx={{ mt: 2, p: 2, backgroundColor: 'white', borderRadius: 1, border: '1px solid #e0e0e0' }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold', color: '#1976d2' }}>
Key Insights:
</Typography>
<Typography variant="body2" color="text.secondary">
{results.supported_claims > 0 && `${results.supported_claims} claim${results.supported_claims > 1 ? 's' : ''} verified with supporting evidence`}
{results.supported_claims > 0 && results.refuted_claims > 0 && ' • '}
{results.refuted_claims > 0 && `${results.refuted_claims} claim${results.refuted_claims > 1 ? 's' : ''} contradicted by sources`}
{results.insufficient_claims > 0 && (results.supported_claims > 0 || results.refuted_claims > 0) && ' • '}
{results.insufficient_claims > 0 && `⚠️ ${results.insufficient_claims} claim${results.insufficient_claims > 1 ? 's' : ''} need more evidence`}
</Typography>
</Box>
{results.processing_time_ms && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Analysis completed in {results.processing_time_ms}ms using AI-powered fact-checking
</Typography>
)}
</Box>
{/* Claims */}
<Box>
<Typography variant="h6" gutterBottom>
Claims Analysis
</Typography>
{results.claims.map((claim, index) => (
<Box
key={index}
sx={{
border: '1px solid #e0e0e0',
borderRadius: 1,
mb: 2,
overflow: 'hidden'
}}
>
{/* Claim Header */}
<Box
sx={{
p: 2,
backgroundColor: '#fafafa',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer'
}}
onClick={() => toggleClaimExpansion(index)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
{getAssessmentIcon(claim.assessment)}
<Typography variant="body1" sx={{ flex: 1 }}>
{claim.text}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`${Math.round(claim.confidence * 100)}%`}
size="small"
sx={{
backgroundColor: getConfidenceColor(claim.confidence),
color: 'white'
}}
/>
<Chip
label={claim.assessment.replace('_', ' ')}
size="small"
sx={{
backgroundColor: getAssessmentColor(claim.assessment),
color: 'white'
}}
/>
{expandedClaims.has(index) ? <ExpandLess /> : <ExpandMore />}
</Box>
</Box>
{/* Claim Details */}
<Collapse in={expandedClaims.has(index)}>
<Box sx={{ p: 2 }}>
{/* Reasoning Section */}
<Box sx={{ mb: 2, p: 2, backgroundColor: '#f8f9fa', borderRadius: 1, border: '1px solid #e9ecef' }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold', color: '#495057' }}>
Analysis Reasoning:
</Typography>
{claim.reasoning ? (
<Typography variant="body2" color="text.secondary">
{claim.reasoning}
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No detailed reasoning available for this assessment.
</Typography>
)}
</Box>
{/* Supporting Sources */}
{claim.supporting_sources.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="success.main" gutterBottom>
Supporting Sources ({claim.supporting_sources.length})
</Typography>
{claim.supporting_sources.map((source, sourceIndex) => (
<Box
key={sourceIndex}
sx={{
p: 1,
mb: 1,
backgroundColor: '#e8f5e8',
borderRadius: 1,
border: '1px solid #c8e6c9'
}}
>
<Link
href={source.url}
target="_blank"
rel="noopener noreferrer"
sx={{ fontWeight: 'bold', textDecoration: 'none' }}
>
{source.title}
</Link>
<Typography variant="caption" display="block" color="text.secondary">
<strong>Relevance Score:</strong> {Math.round(source.score * 100)}%
{source.author && ` • Author: ${source.author}`}
{source.published_date && ` • Published: ${source.published_date}`}
</Typography>
{source.text && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
Relevant Excerpt:
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, fontStyle: 'italic', backgroundColor: 'rgba(0,0,0,0.05)', p: 1, borderRadius: 0.5 }}>
"{source.text.substring(0, 300)}{source.text.length > 300 ? '...' : ''}"
</Typography>
</Box>
)}
</Box>
))}
</Box>
)}
{/* Refuting Sources */}
{claim.refuting_sources.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="error.main" gutterBottom>
Refuting Sources ({claim.refuting_sources.length})
</Typography>
{claim.refuting_sources.map((source, sourceIndex) => (
<Box
key={sourceIndex}
sx={{
p: 1,
mb: 1,
backgroundColor: '#ffebee',
borderRadius: 1,
border: '1px solid #ffcdd2'
}}
>
<Link
href={source.url}
target="_blank"
rel="noopener noreferrer"
sx={{ fontWeight: 'bold', textDecoration: 'none' }}
>
{source.title}
</Link>
<Typography variant="caption" display="block" color="text.secondary">
<strong>Relevance Score:</strong> {Math.round(source.score * 100)}%
{source.author && ` • Author: ${source.author}`}
{source.published_date && ` • Published: ${source.published_date}`}
</Typography>
{source.text && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
Relevant Excerpt:
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, fontStyle: 'italic', backgroundColor: 'rgba(0,0,0,0.05)', p: 1, borderRadius: 0.5 }}>
"{source.text.substring(0, 300)}{source.text.length > 300 ? '...' : ''}"
</Typography>
</Box>
)}
</Box>
))}
</Box>
)}
{/* No Sources */}
{claim.supporting_sources.length === 0 && claim.refuting_sources.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No sources found for this claim.
</Typography>
)}
</Box>
</Collapse>
</Box>
))}
</Box>
{/* Footer */}
<Box sx={{ mt: 3, pt: 2, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" color="text.secondary">
Analysis completed at {new Date(results.timestamp).toLocaleString()}
</Typography>
</Box>
</Box>
</Box>
);
};
export default FactCheckResults;

View File

@@ -7,16 +7,9 @@ interface HeaderProps {
userPreferences: LinkedInPreferences;
chatHistory: any[];
showPreferencesModal: boolean;
showContextModal: boolean;
context: string;
onPreferencesModalChange: (show: boolean) => void;
onContextModalChange: (show: boolean) => void;
onContextChange: (value: string) => void;
onPreferencesChange: (prefs: Partial<LinkedInPreferences>) => void;
onCopy: () => void;
onClear: () => void;
onClearHistory: () => void;
draft: string;
getHistoryLength: () => number;
}
@@ -24,16 +17,9 @@ export const Header: React.FC<HeaderProps> = ({
userPreferences,
chatHistory,
showPreferencesModal,
showContextModal,
context,
onPreferencesModalChange,
onContextModalChange,
onContextChange,
onPreferencesChange,
onCopy,
onClear,
onClearHistory,
draft,
getHistoryLength
}) => {
const handlePreferenceChange = (key: keyof LinkedInPreferences, value: any) => {
@@ -68,16 +54,8 @@ export const Header: React.FC<HeaderProps> = ({
fontWeight: 700,
letterSpacing: '-0.5px'
}}>
LinkedIn Writer
ALwrity LinkedIn Assistant
</h1>
<p style={{
margin: '6px 0 0 0',
fontSize: '14px',
opacity: 0.9,
fontWeight: 400
}}>
Professional content creation for LinkedIn
</p>
</div>
</div>
@@ -126,13 +104,79 @@ export const Header: React.FC<HeaderProps> = ({
}}>
<div style={{ marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 12px 0', color: '#333', fontSize: '16px', fontWeight: 600 }}>
Content Preferences & Context
Content Preferences & Persona
</h4>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '16px' }}>
<strong>Current Settings:</strong> {userPreferences.tone} tone {userPreferences.industry || 'Not set'} industry {chatHistory.length} messages
</div>
</div>
{/* Persona Section */}
<div style={{
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
background: '#f8f9fa'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<h5 style={{ margin: 0, color: '#2d3748', fontSize: '14px', fontWeight: '600' }}>
Writing Persona
</h5>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#4a5568' }}>
<input
type="radio"
name="personaEnabled"
defaultChecked={true}
style={{ margin: 0 }}
/>
On
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#4a5568' }}>
<input
type="radio"
name="personaEnabled"
style={{ margin: 0 }}
/>
Off
</label>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
background: 'white',
borderRadius: '6px',
border: '1px solid #e2e8f0'
}}>
<div style={{ display: 'flex', gap: '4px' }}>
<span style={{ fontSize: '16px' }}>🎭</span>
<span style={{ fontSize: '16px' }}>🎯</span>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: '#2d3748', marginBottom: '2px' }}>
The Digital Strategist (The Insightful Guide)
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
88% accuracy | Platform: LinkedIn Optimized
</div>
</div>
</div>
<div style={{
marginTop: '8px',
fontSize: '11px',
color: '#666',
fontStyle: 'italic'
}}>
Hover over persona for detailed information
</div>
</div>
{/* Preferences Grid */}
<div style={{
display: 'grid',
@@ -300,111 +344,10 @@ export const Header: React.FC<HeaderProps> = ({
)}
</div>
{/* Context & Notes Button */}
<div
style={{
position: 'relative',
cursor: 'pointer'
}}
onMouseEnter={() => onContextModalChange(true)}
onMouseLeave={() => onContextModalChange(false)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
background: 'rgba(255, 255, 255, 0.15)',
borderRadius: '24px',
border: '1px solid rgba(255, 255, 255, 0.2)',
transition: 'all 0.2s ease',
backdropFilter: 'blur(10px)'
}}>
<span style={{ fontSize: '14px', opacity: 0.9 }}>📝</span>
<span style={{ fontSize: '13px', fontWeight: 600 }}>Context & Notes</span>
<span style={{ fontSize: '10px', opacity: 0.7 }}></span>
</div>
{/* Context & Notes Modal */}
{showContextModal && (
<div style={{
position: 'absolute',
top: '100%',
left: '0',
width: '400px',
background: 'white',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.15)',
border: '1px solid #e9ecef',
padding: '20px',
zIndex: 1000,
marginTop: '8px',
animation: 'slideIn 0.2s ease-out'
}}>
<div style={{ marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 12px 0', color: '#333', fontSize: '16px', fontWeight: 600 }}>
Context & Notes
</h4>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '16px' }}>
Add context, notes, or specific requirements for your LinkedIn content
</div>
</div>
<textarea
value={context}
onChange={(e) => onContextChange(e.target.value)}
placeholder="Add context, notes, or specific requirements for your LinkedIn content..."
style={{
width: '100%',
minHeight: '120px',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical',
background: '#f8f9fa'
}}
/>
</div>
)}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={onCopy}
disabled={!draft.trim()}
style={{
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.1)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: 6,
cursor: draft.trim() ? 'pointer' : 'not-allowed',
fontSize: 14,
fontWeight: 500
}}
>
Copy
</button>
<button
onClick={onClear}
disabled={!draft.trim()}
style={{
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.1)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: 6,
cursor: draft.trim() ? 'pointer' : 'not-allowed',
fontSize: 14,
fontWeight: 500
}}
>
Clear
</button>
<button
onClick={onClearHistory}
style={{

View File

@@ -156,10 +156,12 @@ export function useLinkedInWriter() {
};
const handleProgressComplete = () => {
console.log('[LinkedIn Writer] Progress completed - hiding progress tracker');
setProgressSteps(prev => prev.map(s => s.status === 'completed' ? s : { ...s, status: 'completed', timestamp: new Date().toISOString() }));
setProgressActive(false);
// Keep progress visible for a moment to show completion, then hide
setTimeout(() => {
console.log('[LinkedIn Writer] Hiding progress steps after delay');
setProgressSteps([]);
}, 1500);
};
@@ -234,6 +236,9 @@ export function useLinkedInWriter() {
// Handle draft updates from CopilotKit actions
useEffect(() => {
const handleUpdateDraft = (event: CustomEvent) => {
console.log('[LinkedIn Writer] Draft updated:', event.detail?.substring(0, 100) + '...');
console.log('[LinkedIn Writer] Draft length:', event.detail?.length);
console.log('[LinkedIn Writer] Setting draft and clearing loading state...');
setDraft(event.detail);
setIsGenerating(false);
setLoadingMessage('');
@@ -243,6 +248,7 @@ export function useLinkedInWriter() {
// Hide progress tracker when content is generated
setProgressActive(false);
setProgressSteps([]);
console.log('[LinkedIn Writer] Draft update complete');
};
const handleAppendDraft = (event: CustomEvent) => {
@@ -255,15 +261,18 @@ export function useLinkedInWriter() {
const handleLoadingStart = (event: CustomEvent) => {
const { action, message } = event.detail;
console.log('[LinkedIn Writer] Loading started:', { action, message });
setCurrentAction(action);
setLoadingMessage(message);
setIsGenerating(true);
};
const handleLoadingEnd = (event: CustomEvent) => {
console.log('[LinkedIn Writer] Loading ended - clearing all loading states');
setIsGenerating(false);
setLoadingMessage('');
setCurrentAction(null);
console.log('[LinkedIn Writer] Loading state cleared');
};
const handleApplyEdit = (event: CustomEvent) => {

View File

@@ -13,12 +13,6 @@ export function formatDraftContent(content: string, citations?: any[], researchS
// Insert inline citations if available
if (citations && citations.length > 0 && researchSources && researchSources.length > 0) {
console.log('🔍 [formatDraftContent] Processing citations:', {
citationsCount: citations.length,
researchSourcesCount: researchSources.length,
citations: citations,
contentLength: content.length
});
// Create a map of citation references to source numbers
const citationMap = new Map();
@@ -28,8 +22,6 @@ export function formatDraftContent(content: string, citations?: any[], researchS
citationMap.set(citation.reference, sourceNum);
}
});
console.log('🔍 [formatDraftContent] Citation map created:', citationMap);
// Since citation references don't exist in the content text,
// we need to insert citations strategically throughout the content
@@ -51,26 +43,13 @@ export function formatDraftContent(content: string, citations?: any[], researchS
const sentenceWithCitation = targetSentence.trim() + citeHtml;
sentencesWithCitations[targetSentenceIndex] = sentenceWithCitation;
console.log(`✅ [formatDraftContent] Added citation [${sourceNum}] to sentence ${targetSentenceIndex + 1}`);
});
// Reconstruct content with citations
formatted = sentences.map((sentence, index) => {
return sentencesWithCitations[index] || sentence;
}).join('. ') + '.';
console.log(`✅ [formatDraftContent] Inserted ${totalCitations} citations strategically throughout content`);
// Debug: Show sample of content with citations
const sampleContent = formatted.substring(0, 500) + (formatted.length > 500 ? '...' : '');
console.log('🔍 [formatDraftContent] Sample content with citations:', sampleContent);
// Debug: Count citation markers in final content
const citationMarkers = (formatted.match(/\[\d+\]/g) || []).length;
console.log(`🔍 [formatDraftContent] Found ${citationMarkers} citation markers in final content`);
}
console.log('🔍 [formatDraftContent] Final formatted content length:', formatted.length);
}
// Format hashtags

View File

@@ -5,6 +5,9 @@
import { useCopilotContext } from '@copilotkit/react-core';
// Optional debug flag: set to true to enable verbose logs locally
const DEBUG_PERSISTENCE = false;
// Storage keys for different types of data
export const STORAGE_KEYS = {
CHAT_HISTORY: 'alwrity-copilot-chat-history',
@@ -198,7 +201,7 @@ export class CopilotPersistenceManager {
public saveDraftContent(draft: string): void {
try {
localStorage.setItem(STORAGE_KEYS.DRAFT_CONTENT, draft);
console.log('💾 Saved draft content');
if (DEBUG_PERSISTENCE) console.log('💾 Saved draft content');
} catch (error) {
console.error('❌ Failed to save draft content:', error);
}

View File

@@ -0,0 +1,257 @@
import React, { useEffect } from 'react';
interface CitationHoverHandlerProps {
researchSources: any[];
}
// Extend Element interface for our custom property
interface ExtendedElement extends Element {
_liwTip?: HTMLDivElement | null;
}
const CitationHoverHandler: React.FC<CitationHoverHandlerProps> = ({ researchSources }) => {
useEffect(() => {
if (!researchSources || researchSources.length === 0) return;
console.log('🔍 [Citation Hover] useEffect triggered with', researchSources.length, 'sources');
// Keep track of currently open tooltip
let currentOpenTooltip: HTMLDivElement | null = null;
const initCitationHover = () => {
try {
console.log('🔍 [Citation Hover] Script starting...');
console.log('🔍 [Citation Hover] Research sources count:', researchSources.length);
// Test if script is running
document.body.style.setProperty('--citation-hover-active', 'true');
console.log('🔍 [Citation Hover] Script is running, CSS variable set');
// Wait for content to be rendered
const waitForCitations = () => {
const citations = document.querySelectorAll('.liw-cite');
console.log('🔍 [Citation Hover] Looking for citations, found:', citations.length);
if (citations.length === 0) {
// If no citations found, wait a bit and try again
console.log('🔍 [Citation Hover] No citations found, waiting...');
setTimeout(waitForCitations, 200);
return;
}
console.log('🔍 [Citation Hover] Found', citations.length, 'citation elements');
citations.forEach((cite, idx) => {
console.log(`🔍 [Citation Hover] Citation ${idx}: ${cite.outerHTML}`);
console.log(`🔍 [Citation Hover] Citation classes: ${cite.className}`);
console.log(`🔍 [Citation Hover] Citation data-source-index: ${cite.getAttribute('data-source-index')}`);
});
setupCitationHover();
};
const setupCitationHover = () => {
console.log('🔍 [Citation Hover] Initializing hover functionality...');
const data = researchSources;
console.log('🔍 [Citation Hover] Research data loaded:', data.length, 'sources');
const openOverlay = (idx: string, src: any) => {
console.log('🔍 [Citation Hover] Opening overlay for source', idx, src);
const existing = document.getElementById('liw-cite-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'liw-cite-overlay';
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.background = 'rgba(0,0,0,0.35)';
overlay.style.backdropFilter = 'blur(2px)';
overlay.style.zIndex = '100000';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
const modal = document.createElement('div');
modal.style.width = 'min(720px, 92vw)';
modal.style.maxHeight = '80vh';
modal.style.overflow = 'auto';
modal.style.borderRadius = '14px';
modal.style.background = 'linear-gradient(180deg, #ffffff, #f8fdff)';
modal.style.border = '1px solid #cfe9f7';
modal.style.boxShadow = '0 24px 80px rgba(10,102,194,0.25)';
modal.style.padding = '18px 20px';
const title = (src.title || 'Untitled').replace(/</g, '&lt;');
const url = (src.url || '').replace(/</g, '&lt;');
const sourceType = src.source_type ? String(src.source_type).replace('_', ' ') : '';
modal.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
'<div style="font-size:16px;font-weight:800;color:#0a66c2">Source ' + idx + '</div>' +
'<button id="liw-cite-close" style="border:none;background:#eff6ff;color:#0a66c2;border-radius:8px;padding:8px 12px;cursor:pointer;font-weight:700">✕ Close</button>' +
'</div>' +
'<div style="font-size:18px;font-weight:700;color:#1f2937;margin-bottom:8px">' + title + '</div>' +
'<a href="' + (src.url || '#') + '" target="_blank" style="display:inline-block;color:#0a66c2;text-decoration:none;margin-bottom:12px;font-size:14px;font-weight:600;">View Source →</a>' +
(src.content ? '<div style="margin-bottom:16px;color:#374151;font-size:14px;line-height:1.6;background:#f9fafb;padding:16px;border-radius:8px;border-left:4px solid #0a66c2;">' + src.content + '</div>' : '') +
'<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">' +
(typeof src.relevance_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Relevance: ' + Math.round(src.relevance_score * 100) + '%</span>' : '') +
(typeof src.credibility_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Credibility: ' + Math.round(src.credibility_score * 100) + '%</span>' : '') +
(typeof src.domain_authority === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Authority: ' + Math.round(src.domain_authority * 100) + '%</span>' : '') +
'</div>' +
'<div style="display:flex;gap:16px;color:#6b7280;font-size:13px;padding-top:12px;border-top:1px solid #e5e7eb">' +
(src.source_type ? '<div>Type: <span style="color:#374151;font-weight:600">' + src.source_type.replace('_', ' ') + '</span></div>' : '') +
(src.publication_date ? '<div>Published: <span style="color:#374151;font-weight:600">' + src.publication_date + '</span></div>' : '') +
'</div>' +
(src.raw_result ? '<div style="color:#6b7280;font-size:12px;margin-top:12px;padding:8px;background:#f3f4f6;border-radius:6px;border-top:1px solid #e5e7eb;">Raw Data: ' + JSON.stringify(src.raw_result).substring(0, 150) + (JSON.stringify(src.raw_result).length > 150 ? '...' : '') + '</div>' : '');
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => {
try { overlay.remove(); } catch(_){}
};
overlay.addEventListener('click', (e) => {
if(e.target === overlay) close();
});
document.getElementById('liw-cite-close')?.addEventListener('click', close);
document.addEventListener('keydown', function esc(ev: KeyboardEvent) {
if(ev.key === 'Escape') {
close();
document.removeEventListener('keydown', esc);
}
});
};
// Add event listeners directly to each citation element
const citations = document.querySelectorAll('.liw-cite');
citations.forEach((cite) => {
console.log('🔍 [Citation Hover] Adding event listeners to citation:', cite.outerHTML);
cite.addEventListener('mouseenter', () => {
console.log('🔍 [Citation Hover] Mouse enter on citation:', cite.outerHTML);
// Close any existing tooltip first
if (currentOpenTooltip) {
try { currentOpenTooltip.remove(); } catch(_) {}
currentOpenTooltip = null;
}
const idx = cite.getAttribute('data-source-index');
console.log('🔍 [Citation Hover] Citation index:', idx);
if (!idx) return;
const i = parseInt(idx, 10) - 1;
const src = data[i];
if (!src) {
console.log('🔍 [Citation Hover] No source found for index:', idx);
return;
}
console.log('🔍 [Citation Hover] Creating tooltip for source:', src);
let tip = document.createElement('div');
tip.className = 'liw-cite-tip';
tip.style.position = 'fixed';
tip.style.zIndex = '99999';
tip.style.maxWidth = '420px';
tip.style.background = 'linear-gradient(180deg, #ffffff, #f8fdff)';
tip.style.border = '1px solid #cfe9f7';
tip.style.borderRadius = '10px';
tip.style.boxShadow = '0 12px 40px rgba(10,102,194,0.18)';
tip.style.padding = '12px 14px';
tip.style.fontSize = '12px';
tip.style.color = '#1f2937';
tip.style.backdropFilter = 'blur(5px)';
const title = (src.title || 'Untitled').replace(/</g, '&lt;');
const url = (src.url || '').replace(/</g, '&lt;');
const sourceType = src.source_type ? String(src.source_type).replace('_', ' ') : '';
tip.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<div style="font-weight:700;color:#0a66c2">Source ' + idx + '</div>' +
'<button class="liw-pin" title="Pin" style="border:none;background:#eef6ff;border-radius:8px;padding:4px 8px;cursor:pointer;color:#0a66c2;font-weight:800">📌</button>' +
'</div>' +
'<div style="font-weight:600;margin-bottom:6px;color:#1f2937">' + title + '</div>' +
'<a href="' + (src.url || '#') + '" target="_blank" style="color:#0a66c2;text-decoration:none;margin-bottom:8px;display:block;font-weight:600;">View Source →</a>' +
(src.content ? '<div style="margin-bottom:8px;color:#374151;font-size:11px;line-height:1.4;background:#f9fafb;padding:8px;border-radius:6px;border-left:3px solid #0a66c2;">' + src.content + '</div>' : '') +
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">' +
(typeof src.relevance_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Relevance: ' + Math.round(src.relevance_score * 100) + '%</span>' : '') +
(typeof src.credibility_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Credibility: ' + Math.round(src.credibility_score * 100) + '%</span>' : '') +
(typeof src.domain_authority === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Authority: ' + Math.round(src.domain_authority * 100) + '%</span>' : '') +
'</div>' +
(src.source_type ? '<div style="color:#6b7280;font-size:11px;margin-bottom:4px">Type: <span style="color:#374151;font-weight:600">' + src.source_type.replace('_', ' ') + '</span></div>' : '') +
(src.publication_date ? '<div style="color:#6b7280;font-size:11px">Published: <span style="color:#374151;font-weight:600">' + src.publication_date + '</span></div>' : '') +
(src.raw_result ? '<div style="color:#6b7280;font-size:11px;margin-top:4px;padding:4px;background:#f3f4f6;border-radius:4px;">Raw Data: ' + JSON.stringify(src.raw_result).substring(0, 100) + (JSON.stringify(src.raw_result).length > 100 ? '...' : '') + '</div>' : '');
document.body.appendChild(tip);
const rect = cite.getBoundingClientRect();
tip.style.left = Math.min(rect.left, window.innerWidth - 460) + 'px';
tip.style.top = (rect.bottom + 8) + 'px';
tip.querySelector('.liw-pin')?.addEventListener('click', (ev) => {
ev.stopPropagation();
openOverlay(idx, src);
try { tip.remove(); } catch(_) {
// Remove the custom property reference
const extendedTip = tip as any;
extendedTip._liwTip = undefined;
}
currentOpenTooltip = null;
});
(cite as ExtendedElement)._liwTip = tip;
currentOpenTooltip = tip;
console.log('🔍 [Citation Hover] Tooltip created and positioned');
});
cite.addEventListener('mouseleave', () => {
console.log('🔍 [Citation Hover] Mouse leave on citation:', cite.outerHTML);
const extendedCite = cite as ExtendedElement;
if (extendedCite._liwTip) {
try { extendedCite._liwTip.remove(); } catch(_) {}
extendedCite._liwTip = null;
currentOpenTooltip = null;
}
});
});
console.log('✅ [Citation Hover] Hover functionality initialized for', citations.length, 'citations');
};
// Start waiting for citations with a longer delay to ensure content is rendered
setTimeout(waitForCitations, 500);
} catch(e: any) {
console.warn('liw cite tooltip init failed', e);
console.error('Error details:', e);
// Show error in UI
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'position:fixed;top:10px;right:10px;background:#ffebee;border:1px solid #f44336;border-radius:4px;padding:10px;z-index:100000;color:#c62828;';
errorDiv.innerHTML = 'Citation hover failed: ' + e.message;
document.body.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
}
};
// Initialize citation hover after a short delay to ensure content is rendered
const timer = setTimeout(initCitationHover, 100);
// Cleanup function
return () => {
clearTimeout(timer);
// Remove any existing tooltips
const tooltips = document.querySelectorAll('.liw-cite-tip');
tooltips.forEach(tip => tip.remove());
// Remove overlay if exists
const overlay = document.getElementById('liw-cite-overlay');
if (overlay) overlay.remove();
// Reset current tooltip reference
currentOpenTooltip = null;
};
}, [researchSources]); // Dependency on researchSources
// This component doesn't render anything visible
return null;
};
export default CitationHoverHandler;

View File

@@ -0,0 +1,279 @@
import React, { useMemo, useEffect, useRef, useState } from 'react';
import { formatDraftContent } from '../LinkedInWriter/utils/contentFormatters';
import WritingAssistantCard from './WritingAssistantCard';
import { WASuggestion } from '../../services/writingAssistantService';
interface ContentDisplayAreaProps {
contentRef: React.RefObject<HTMLDivElement>;
draft: string;
isGenerating: boolean;
loadingMessage: string;
citations?: any[];
researchSources?: any[];
assistantOn: boolean;
waSuggestion: WASuggestion | null;
waError?: string | null;
showContinuePrompt?: boolean;
onDraftChange: (value: string) => void;
onDismissSuggestion: () => void;
onTextSelection: () => void;
renderSelectionMenu: () => React.ReactNode;
onTriggerSuggestion?: (text: string, caretIndex?: number) => void;
onInsertWithPreview?: (text: string, caretIndex: number) => void;
onContinueWriting?: () => void;
}
const ContentDisplayArea: React.FC<ContentDisplayAreaProps> = ({
contentRef,
draft,
isGenerating,
loadingMessage,
citations,
researchSources,
assistantOn,
waSuggestion,
waError,
showContinuePrompt,
onDraftChange,
onDismissSuggestion,
onTextSelection,
renderSelectionMenu,
onTriggerSuggestion,
onInsertWithPreview,
onContinueWriting
}) => {
const [localDraft, setLocalDraft] = useState<string>(draft);
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
const suggestionTimerRef = useRef<NodeJS.Timeout | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [caretRect, setCaretRect] = useState<{ top: number; left: number } | null>(null);
const [currentCaretIndex, setCurrentCaretIndex] = useState<number>(0);
const updateCaretRect = (el: HTMLTextAreaElement) => {
const index = el.selectionStart ?? 0;
setCurrentCaretIndex(index);
const container = contentRef.current as HTMLDivElement | null;
const containerRect = container?.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const lineHeight = 22;
const textUntilCaret = el.value.slice(0, index);
const lines = textUntilCaret.split('\n');
const lastLine = lines[lines.length - 1];
const approxCharWidth = 7.2;
const caretTopViewport = elRect.top + 12 + (lines.length - 1) * lineHeight;
const caretLeftViewport = elRect.left + 12 + lastLine.length * approxCharWidth;
if (containerRect) {
const top = caretTopViewport - containerRect.top + (container?.scrollTop || 0);
const left = caretLeftViewport - containerRect.left + (container?.scrollLeft || 0);
setCaretRect({ top, left });
} else {
setCaretRect({ top: caretTopViewport + window.scrollY, left: caretLeftViewport + window.scrollX });
}
};
// Memoize the formatted content to prevent infinite re-rendering
const formattedContent = useMemo(() => {
if (!draft) return '';
return formatDraftContent(draft, citations, researchSources);
}, [draft, citations, researchSources]);
// Keep local textarea in sync with external updates (including confirmed diffs)
useEffect(() => {
if (draft !== localDraft) {
setLocalDraft(draft);
}
}, [draft]);
// Cleanup debounced saver
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (suggestionTimerRef.current) clearTimeout(suggestionTimerRef.current);
};
}, []);
return (
<div
ref={contentRef}
onMouseUp={assistantOn ? undefined : onTextSelection}
style={{
padding: '20px',
minHeight: '400px',
lineHeight: '1.6',
position: 'relative',
userSelect: 'text',
overflow: 'visible'
}}
>
{/* Inline Writing Suggestion Card (anchored near caret when editing) */}
<WritingAssistantCard
assistantOn={assistantOn}
waSuggestion={waSuggestion}
waError={waError}
showContinuePrompt={showContinuePrompt}
draft={draft}
onDraftChange={onDraftChange}
onDismissSuggestion={onDismissSuggestion}
anchor={assistantOn ? caretRect : null}
caretIndex={currentCaretIndex}
onInsertAtCaret={onInsertWithPreview}
onContinueWriting={onContinueWriting}
/>
{/* Loading State */}
{isGenerating && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
zIndex: 10
}}>
<div style={{
width: '40px',
height: '40px',
border: '3px solid #e1f5fe',
borderTop: '3px solid #0a66c2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px auto'
}} />
<div style={{
color: '#0277bd',
fontSize: '16px',
fontWeight: '500',
marginBottom: '8px'
}}>
{loadingMessage || 'Generating LinkedIn content...'}
</div>
<div style={{
color: '#666',
fontSize: '14px',
maxWidth: '300px',
lineHeight: '1.4'
}}>
Crafting professional content tailored to your industry and audience...
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)}
{/* Content Display */}
<div style={{
opacity: isGenerating ? 0.3 : 1,
transition: 'opacity 0.3s ease'
}}>
{draft ? (
<div>
{assistantOn ? (
<textarea
ref={textareaRef}
value={localDraft}
onChange={(e) => {
const value = e.target.value;
setLocalDraft(value);
const caretIndex = e.target.selectionStart ?? value.length;
// Debounce suggestion trigger to avoid per-keystroke calls
if (suggestionTimerRef.current) clearTimeout(suggestionTimerRef.current);
if (onTriggerSuggestion) {
suggestionTimerRef.current = setTimeout(() => {
onTriggerSuggestion(value, caretIndex);
}, 800);
}
// Update caret rect for popover placement
updateCaretRect(e.currentTarget);
// If user is typing while a suggestion is visible, hide it immediately
if (waSuggestion && onDismissSuggestion) {
onDismissSuggestion();
}
// Debounce the draft save
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
onDraftChange(value);
}, 600);
}}
onKeyUp={(e) => updateCaretRect(e.currentTarget)}
autoFocus
style={{
width: '100%',
minHeight: '300px',
outline: 'none',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '12px',
background: '#fff',
fontFamily: 'inherit',
fontSize: '14px',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
resize: 'vertical'
}}
/>
) : (
<div dangerouslySetInnerHTML={{ __html: formattedContent }} />
)}
</div>
) : (
<p style={{
color: '#666',
fontStyle: 'italic',
textAlign: 'center',
marginTop: '40px'
}}>
Content will appear here when generated. Use the AI assistant to create your LinkedIn content.
</p>
)}
{/* Citation Styling */}
<style>{`
.liw-cite {
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
border: 1px solid #64b5f6;
border-radius: 4px;
padding: 2px 6px;
margin: 0 2px;
font-size: 0.8em;
font-weight: 600;
color: #1976d2;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(25, 118, 210, 0.1);
}
.liw-cite:hover {
background: linear-gradient(135deg, #bbdefb, #90caf9);
border-color: #42a5f5;
box-shadow: 0 4px 8px rgba(25, 118, 210, 0.2);
transform: translateY(-1px);
}
.liw-cite:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(25, 118, 210, 0.1);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
{/* Text Selection Menu and Fact-Check Components (disabled while editing) */}
{!assistantOn && renderSelectionMenu()}
</div>
</div>
);
};
export default ContentDisplayArea;

View File

@@ -0,0 +1,850 @@
import React, { useMemo, useState, useEffect } from 'react';
// Extend HTMLDivElement interface for custom tooltip properties
interface ExtendedDivElement extends HTMLDivElement {
_researchTooltip?: HTMLDivElement | null;
_citationsTooltip?: HTMLDivElement | null;
_searchQueriesTooltip?: HTMLDivElement | null;
_qualityTooltip?: HTMLDivElement | null;
_researchTooltipTimeout?: NodeJS.Timeout | null;
_qualityTooltipTimeout?: NodeJS.Timeout | null;
}
interface ContentPreviewHeaderProps {
researchSources?: any[];
citations?: any[];
searchQueries?: string[];
qualityMetrics?: any;
draft: string;
showPreview: boolean;
onPreviewToggle: () => void;
assistantOn?: boolean;
onAssistantToggle?: (enabled: boolean) => void;
topic?: string;
}
const ContentPreviewHeader: React.FC<ContentPreviewHeaderProps> = ({
researchSources,
citations,
searchQueries,
qualityMetrics,
draft,
showPreview,
onPreviewToggle,
assistantOn,
onAssistantToggle,
topic
}) => {
const formatPercent = (v?: number) => typeof v === 'number' ? `${Math.round(v * 100)}%` : '—';
const getChipColor = (v?: number) => {
if (typeof v !== 'number') return '#6b7280';
if (v >= 0.8) return '#10b981';
if (v >= 0.6) return '#f59e0b';
return '#ef4444';
};
// Memoize chips array to prevent infinite re-rendering
const chips = useMemo(() => {
const chipArray = qualityMetrics ? [
{ label: 'Overall', value: qualityMetrics.overall_score },
{ label: 'Accuracy', value: qualityMetrics.factual_accuracy },
{ label: 'Verification', value: qualityMetrics.source_verification },
{ label: 'Coverage', value: qualityMetrics.citation_coverage }
] : [];
console.log('🔍 [ContentPreviewHeader] Chips array created:', {
qualityMetrics: qualityMetrics,
chips: chipArray,
chipsLength: chipArray.length
});
return chipArray;
}, [qualityMetrics]);
// Helper to build descriptive chip tooltip text
const chipDescriptions: Record<string, string> = {
Overall: 'Overall blends accuracy, verification and coverage into a single reliability score for this draft.',
Accuracy: 'Factual Accuracy estimates how likely statements are to be factually correct based on grounding signals.',
Verification: 'Source Verification reflects how well claims are linked to credible sources and whether citations match claims.',
Coverage: 'Citation Coverage indicates how much of the content is supported with citations. Higher is better.'
};
return (
<div style={{
padding: '12px 16px',
background: '#e1f5fe',
borderBottom: '1px solid #b3e5fc',
fontSize: '12px',
fontWeight: '600',
color: '#0277bd',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span>{topic ? `${topic} - LinkedIn Content Preview` : 'LinkedIn Content Preview'}</span>
{/* Research Chip with Hover Sub-chips */}
{((researchSources && researchSources.length > 0) || (citations && citations.length > 0) || (searchQueries && searchQueries.length > 0)) && (
<div style={{ position: 'relative' }}>
{/* Main Research Chip */}
<div
style={{
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
border: '1px solid #0284c7',
borderRadius: '999px',
padding: '6px 14px',
fontSize: '11px',
fontWeight: '700',
color: 'white',
cursor: 'pointer',
transition: 'all 0.3s ease',
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 8px rgba(14, 165, 233, 0.3)',
transform: 'translateZ(0)',
userSelect: 'none'
}}
title="Research data available. Hover to see sources, citations, and queries."
onMouseEnter={(e) => {
// Clear any existing timeout
const target = e.currentTarget as ExtendedDivElement;
if (target._researchTooltipTimeout) {
clearTimeout(target._researchTooltipTimeout);
target._researchTooltipTimeout = null;
}
// Create and show research sub-chips tooltip
const tooltip = document.createElement('div');
tooltip.style.cssText = `
position: fixed;
z-index: 100000;
background: white;
border: 1px solid #cfe9f7;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
padding: 16px;
max-width: 400px;
font-size: 12px;
opacity: 0;
transform: translateY(-8px);
transition: all 0.2s ease;
pointer-events: auto;
`;
let subChipsHtml = '<div style="margin-bottom: 12px; font-weight: 600; color: #0a66c2; font-size: 14px;">Research Data</div>';
// Add Sources sub-chip
if (researchSources && researchSources.length > 0) {
subChipsHtml += `
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
onmouseenter="this.style.background='#e0f2fe'; this.style.transform='scale(1.05)'"
onmouseleave="this.style.background='#f0f9ff'; this.style.transform='scale(1)'"
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showResearchSourcesModal', { detail: 'sources' }))">
<span style="display: inline-block; width: 6px; height: 6px; background: #10b981; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(16, 185, 129, 0.5);"></span>
Sources: ${researchSources.length}
</div>
`;
}
// Add Citations sub-chip
if (citations && citations.length > 0) {
subChipsHtml += `
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
onmouseenter="this.style.background='#fde68a'; this.style.transform='scale(1.05)'"
onmouseleave="this.style.background='#fef3c7'; this.style.transform='scale(1)'"
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showCitationsModal', { detail: 'citations' }))">
<span style="display: inline-block; width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);"></span>
Citations: ${citations.length}
</div>
`;
}
// Add Queries sub-chip
if (searchQueries && searchQueries.length > 0) {
subChipsHtml += `
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f3e8ff; border: 1px solid #8b5cf6; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
onmouseenter="this.style.background='#e9d5ff'; this.style.transform='scale(1.05)'"
onmouseleave="this.style.background='#f3e8ff'; this.style.transform='scale(1)'"
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showSearchQueriesModal', { detail: 'queries' }))">
<span style="display: inline-block; width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(139, 92, 246, 0.5);"></span>
Queries: ${searchQueries.length}
</div>
`;
}
tooltip.innerHTML = subChipsHtml;
// Add mouse events to tooltip to keep it visible
tooltip.addEventListener('mouseenter', () => {
if (target._researchTooltipTimeout) {
clearTimeout(target._researchTooltipTimeout);
target._researchTooltipTimeout = null;
}
});
tooltip.addEventListener('mouseleave', () => {
target._researchTooltipTimeout = setTimeout(() => {
if (tooltip.parentNode) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'translateY(-8px)';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.remove();
}
}, 200);
}
target._researchTooltip = null;
}, 100);
});
document.body.appendChild(tooltip);
const rect = e.currentTarget.getBoundingClientRect();
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
tooltip.style.top = (rect.bottom + 8) + 'px';
// Animate in
setTimeout(() => {
tooltip.style.opacity = '1';
tooltip.style.transform = 'translateY(0)';
}, 10);
target._researchTooltip = tooltip;
}}
onMouseLeave={(e) => {
const target = e.currentTarget as ExtendedDivElement;
if (target._researchTooltip) {
// Add delay before hiding to allow moving to tooltip
target._researchTooltipTimeout = setTimeout(() => {
const tooltip = target._researchTooltip;
if (tooltip && tooltip.parentNode) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'translateY(-8px)';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.remove();
}
}, 200);
}
target._researchTooltip = null;
}, 100);
}
}}
onMouseMove={(e) => {
// Keep tooltip visible when moving to sub-chips
const target = e.currentTarget as ExtendedDivElement;
if (target._researchTooltip) {
const tooltip = target._researchTooltip;
const rect = e.currentTarget.getBoundingClientRect();
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
tooltip.style.top = (rect.bottom + 8) + 'px';
}
}}
onMouseOver={(e) => {
// Add hover effect to the chip itself
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(14, 165, 233, 0.4)';
}}
onMouseOut={(e) => {
// Remove hover effect
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
}}
>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.9)',
flexShrink: 0,
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
}} />
Research
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{/* Quality Metrics Chip */}
{chips.length > 0 && (
<div style={{ position: 'relative' }}>
{/* Main Quality Metrics Chip */}
<div
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: '1px solid #047857',
borderRadius: '999px',
padding: '6px 14px',
fontSize: '11px',
fontWeight: '700',
color: 'white',
cursor: 'pointer',
transition: 'all 0.3s ease',
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
transform: 'translateZ(0)',
userSelect: 'none'
}}
title="Quality metrics available. Hover to see detailed progress bars and explanations."
onMouseEnter={(e) => {
// Clear any existing timeout
const target = e.currentTarget as ExtendedDivElement;
if (target._qualityTooltipTimeout) {
clearTimeout(target._qualityTooltipTimeout);
target._qualityTooltipTimeout = null;
}
// Create and show quality metrics tooltip with circular progress bars
const tooltip = document.createElement('div');
tooltip.style.cssText = `
position: fixed;
z-index: 100000;
background: white;
border: 1px solid #d1fae5;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
padding: 24px;
max-width: 500px;
font-size: 12px;
opacity: 0;
transform: translateY(-8px);
transition: all 0.2s ease;
pointer-events: auto;
`;
// Create circular progress bars for each metric
const createCircularProgress = (label: string, value: number, description: string) => {
const percentage = Math.round(value * 100);
const color = getChipColor(value);
const circumference = 2 * Math.PI * 45; // radius = 45
const strokeDasharray = circumference;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return `
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 12px; background: #f8fafc; border-radius: 12px; border-left: 4px solid ${color};">
<div style="position: relative; width: 60px; height: 60px;">
<svg width="60" height="60" style="transform: rotate(-90deg);">
<circle cx="30" cy="30" r="45" stroke="#e5e7eb" stroke-width="6" fill="none"/>
<circle cx="30" cy="30" r="45" stroke="${color}" stroke-width="6" fill="none"
stroke-dasharray="${strokeDasharray}" stroke-dashoffset="${strokeDashoffset}"
style="transition: stroke-dashoffset 0.5s ease;"/>
</svg>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: 700; font-size: 14px; color: ${color};">
${percentage}%
</div>
</div>
<div style="flex: 1;">
<div style="font-weight: 700; color: #1f2937; margin-bottom: 4px; font-size: 14px;">${label}</div>
<div style="color: #6b7280; line-height: 1.4; font-size: 11px;">${description}</div>
</div>
</div>
`;
};
let progressBarsHtml = '<div style="margin-bottom: 16px; font-weight: 700; color: #059669; font-size: 16px; text-align: center;">Quality Metrics</div>';
chips.forEach(chip => {
progressBarsHtml += createCircularProgress(
chip.label,
chip.value || 0,
chipDescriptions[chip.label] || ''
);
});
tooltip.innerHTML = progressBarsHtml;
// Add mouse events to tooltip to keep it visible
tooltip.addEventListener('mouseenter', () => {
if (target._qualityTooltipTimeout) {
clearTimeout(target._qualityTooltipTimeout);
target._qualityTooltipTimeout = null;
}
});
tooltip.addEventListener('mouseleave', () => {
target._qualityTooltipTimeout = setTimeout(() => {
if (tooltip.parentNode) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'translateY(-8px)';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.remove();
}
}, 200);
}
target._qualityTooltip = null;
}, 100);
});
document.body.appendChild(tooltip);
const rect = e.currentTarget.getBoundingClientRect();
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
tooltip.style.top = (rect.bottom + 8) + 'px';
// Animate in
setTimeout(() => {
tooltip.style.opacity = '1';
tooltip.style.transform = 'translateY(0)';
}, 10);
target._qualityTooltip = tooltip;
}}
onMouseLeave={(e) => {
const target = e.currentTarget as ExtendedDivElement;
if (target._qualityTooltip) {
// Add delay before hiding to allow moving to tooltip
target._qualityTooltipTimeout = setTimeout(() => {
const tooltip = target._qualityTooltip;
if (tooltip && tooltip.parentNode) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'translateY(-8px)';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.remove();
}
}, 200);
}
target._qualityTooltip = null;
}, 100);
}
}}
onMouseMove={(e) => {
// Keep tooltip visible when moving to progress bars
const target = e.currentTarget as ExtendedDivElement;
if (target._qualityTooltip) {
const tooltip = target._qualityTooltip;
const rect = e.currentTarget.getBoundingClientRect();
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
tooltip.style.top = (rect.bottom + 8) + 'px';
}
}}
onMouseOver={(e) => {
// Add hover effect to the chip itself
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
}}
onMouseOut={(e) => {
// Remove hover effect
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
}}
>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.9)',
flexShrink: 0,
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
}} />
Quality Metrics
</div>
</div>
)}
<span style={{ fontSize: '10px', opacity: 0.8 }}>
{draft.split(/\s+/).length} words {Math.ceil(draft.split(/\s+/).length / 200)} min read
</span>
{/* Assistive Writing toggle */}
{onAssistantToggle && (
<label
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
title="Assistive Writing: Get real-time AI-powered writing suggestions as you type. Uses Exa.ai for web research and Gemini for intelligent content generation. Automatically enables editing mode to allow typing and content modification."
>
<input
type="checkbox"
checked={assistantOn || false}
onChange={(e) => onAssistantToggle(e.target.checked)}
/>
Assistive Writing
</label>
)}
<label
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
title="Toggle preview visibility"
>
<input
type="checkbox"
checked={!showPreview}
onChange={() => onPreviewToggle()}
style={{ margin: 0 }}
/>
Hide Preview
</label>
</div>
</div>
);
};
// Research Sources Modal Component
const ResearchSourcesModal: React.FC<{ sources: any[]; isOpen: boolean; onClose: () => void }> = ({ sources, isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000000
}} onClick={onClose}>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '600px',
maxHeight: '80vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
}} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
Research Sources ({sources.length})
</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#666',
padding: '0',
width: '30px',
height: '30px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
×
</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{sources && Array.isArray(sources) ? sources.map((source, idx) => (
<div key={idx} style={{
marginBottom: '16px',
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
borderLeft: '4px solid #0a66c2'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#0a66c2' }}>
{source.title || 'Untitled Source'}
</div>
<div style={{ color: '#666', marginBottom: '12px', lineHeight: '1.5' }}>
{source.content || 'No description available'}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{source.relevance_score && (
<span style={{
backgroundColor: '#eef6ff',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: '600',
color: '#0a66c2'
}}>
Relevance: {Math.round(source.relevance_score * 100)}%
</span>
)}
{source.credibility_score && (
<span style={{
backgroundColor: '#eef6ff',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: '600',
color: '#0a66c2'
}}>
Credibility: {Math.round(source.credibility_score * 100)}%
</span>
)}
{source.domain_authority && (
<span style={{
backgroundColor: '#eef6ff',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: '600',
color: '#0a66c2'
}}>
Authority: {Math.round(source.domain_authority * 100)}%
</span>
)}
</div>
</div>
)) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
No research sources available
</div>
)}
</div>
</div>
</div>
);
};
// Citations Modal Component
const CitationsModal: React.FC<{ citations: any[]; isOpen: boolean; onClose: () => void }> = ({ citations, isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000000
}} onClick={onClose}>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '500px',
maxHeight: '80vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
}} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
Citations ({citations.length})
</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#666',
padding: '0',
width: '30px',
height: '30px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
×
</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{citations && Array.isArray(citations) ? citations.map((citation, idx) => (
<div key={idx} style={{
marginBottom: '12px',
padding: '12px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
borderLeft: '3px solid #f59e0b'
}}>
<div style={{ fontWeight: '600', color: '#0a66c2', marginBottom: '4px' }}>
Citation {idx + 1}
</div>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
Type: {citation.type || 'inline'}
</div>
{citation.reference && (
<div style={{ color: '#666', fontSize: '12px' }}>
Reference: {citation.reference}
</div>
)}
</div>
)) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
No citations available
</div>
)}
</div>
</div>
</div>
);
};
// Search Queries Modal Component
const SearchQueriesModal: React.FC<{ queries: string[]; isOpen: boolean; onClose: () => void }> = ({ queries, isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000000
}} onClick={onClose}>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '500px',
maxHeight: '80vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
}} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
Search Queries Used ({queries.length})
</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#666',
padding: '0',
width: '30px',
height: '30px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
×
</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{queries && Array.isArray(queries) ? queries.map((query, idx) => (
<div key={idx} style={{
marginBottom: '12px',
padding: '12px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
borderLeft: '3px solid #8b5cf6'
}}>
<div style={{ fontWeight: '600', color: '#7c3aed', marginBottom: '6px' }}>
Query {idx + 1}
</div>
<div style={{ color: '#374151', fontSize: '13px', lineHeight: '1.4' }}>
{query}
</div>
</div>
)) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
No search queries available
</div>
)}
</div>
</div>
</div>
);
};
// Enhanced ContentPreviewHeader with Modal State
const ContentPreviewHeaderWithModals: React.FC<ContentPreviewHeaderProps> = (props) => {
const [showResearchSourcesModal, setShowResearchSourcesModal] = useState(false);
const [showCitationsModal, setShowCitationsModal] = useState(false);
const [showSearchQueriesModal, setShowSearchQueriesModal] = useState(false);
const [modalData, setModalData] = useState<any>(null);
useEffect(() => {
const handleShowResearchSourcesModal = (event: CustomEvent) => {
try {
const dataType = event.detail;
let data: any[] = [];
if (dataType === 'sources') {
data = props.researchSources || [];
}
setModalData(Array.isArray(data) ? data : []);
setShowResearchSourcesModal(true);
} catch (error) {
console.error('Error handling research sources modal:', error);
setModalData([]);
setShowResearchSourcesModal(true);
}
};
const handleShowCitationsModal = (event: CustomEvent) => {
try {
const dataType = event.detail;
let data: any[] = [];
if (dataType === 'citations') {
data = props.citations || [];
}
setModalData(Array.isArray(data) ? data : []);
setShowCitationsModal(true);
} catch (error) {
console.error('Error handling citations modal:', error);
setModalData([]);
setShowCitationsModal(true);
}
};
const handleShowSearchQueriesModal = (event: CustomEvent) => {
try {
const dataType = event.detail;
let data: any[] = [];
if (dataType === 'queries') {
data = props.searchQueries || [];
}
setModalData(Array.isArray(data) ? data : []);
setShowSearchQueriesModal(true);
} catch (error) {
console.error('Error handling search queries modal:', error);
setModalData([]);
setShowSearchQueriesModal(true);
}
};
window.addEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
window.addEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
window.addEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
return () => {
window.removeEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
window.removeEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
window.removeEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
};
}, []);
return (
<>
<ContentPreviewHeader {...props} />
<ResearchSourcesModal
sources={modalData || []}
isOpen={showResearchSourcesModal}
onClose={() => setShowResearchSourcesModal(false)}
/>
<CitationsModal
citations={modalData || []}
isOpen={showCitationsModal}
onClose={() => setShowCitationsModal(false)}
/>
<SearchQueriesModal
queries={modalData || []}
isOpen={showSearchQueriesModal}
onClose={() => setShowSearchQueriesModal(false)}
/>
</>
);
};
export default ContentPreviewHeader;
export { ContentPreviewHeader, ContentPreviewHeaderWithModals };

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { diffMarkup } from '../LinkedInWriter/utils/contentFormatters';
interface DiffPreviewModalProps {
isPreviewing: boolean;
pendingEdit: { src: string; target: string } | null;
livePreviewHtml: string;
onConfirmChanges: () => void;
onDiscardChanges: () => void;
}
const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
isPreviewing,
pendingEdit,
livePreviewHtml,
onConfirmChanges,
onDiscardChanges
}) => {
if (!isPreviewing || !pendingEdit) return null;
return (
<div style={{
margin: '24px',
border: '1px solid #e0e0e0',
borderRadius: 8,
background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}>
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<strong style={{ color: '#0a66c2' }}>Preview Changes</strong>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={onConfirmChanges}
style={{
padding: '6px 12px',
background: '#0a66c2',
color: '#fff',
border: '1px solid #0a66c2',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: 600
}}
>
Confirm Changes
</button>
<button
onClick={onDiscardChanges}
style={{
padding: '6px 12px',
background: '#fff',
color: '#444',
border: '1px solid #ddd',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: 500
}}
>
Discard
</button>
</div>
</div>
<div style={{ padding: 16 }}>
<div
style={{ fontFamily: 'inherit', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{ __html: livePreviewHtml || diffMarkup(pendingEdit.src, pendingEdit.target) }}
/>
<style>{`
.liw-add { background: rgba(46, 204, 113, 0.18); font-style: normal; }
.liw-del { color: #c0392b; text-decoration: line-through; opacity: 0.8; }
.liw-more { color: #999; }
`}</style>
</div>
</div>
);
};
export default DiffPreviewModal;

View File

@@ -0,0 +1,76 @@
import React from 'react';
interface QuickEditToolbarProps {
draft: string;
isPreviewing: boolean;
}
const QuickEditToolbar: React.FC<QuickEditToolbarProps> = ({ draft, isPreviewing }) => {
if (!draft || isPreviewing) return null;
return (
<div style={{
display: 'flex',
gap: 8,
padding: '10px 16px',
borderBottom: '1px solid #eee',
background: '#fafafa'
}}>
<span style={{ fontSize: 12, color: '#666', alignSelf: 'center' }}>
Quick edits (preview):
</span>
<button
onClick={() => {
const lines = draft.split('\n');
if (lines.length > 0) {
const first = lines[0].trim();
lines[0] = first.replace(/^(.*?)([\.!?])?$/, '👉 $1$2');
}
const target = lines.join('\n');
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
}}
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Tighten Hook
</button>
<button
onClick={() => {
const target = draft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
}}
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Add CTA
</button>
<button
onClick={() => {
const target = draft.length > 200 ? draft.substring(0, 200) + '...' : draft;
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
}}
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Shorten
</button>
<button
onClick={() => {
const target = draft + '\n\nThis approach has shown strong results. The key is to maintain consistency while adapting to changing conditions.';
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
}}
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Lengthen
</button>
<button
onClick={() => {
const target = `[Professionalized]` + '\n\n' + draft;
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
}}
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Professionalize
</button>
</div>
);
};
export default QuickEditToolbar;

View File

@@ -0,0 +1,588 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../services/hallucinationDetectorService';
import FactCheckResults from '../LinkedInWriter/components/FactCheckResults';
interface TextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement>;
}
const useTextSelectionHandler = (contentRef: React.RefObject<HTMLDivElement>) => {
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fact-checking functionality
const handleCheckFacts = async (text: string) => {
console.log('🔍 [TextSelectionHandler] handleCheckFacts called with text:', text);
if (!text.trim()) {
console.log('🔍 [TextSelectionHandler] No text to check, returning');
return;
}
console.log('🔍 [TextSelectionHandler] Starting fact check for:', text.trim());
setIsFactChecking(true);
setSelectionMenu(null);
// Progress tracking
const progressSteps = [
{ step: "Extracting verifiable claims...", progress: 20 },
{ step: "Searching for evidence...", progress: 40 },
{ step: "Analyzing claims against sources...", progress: 70 },
{ step: "Generating final assessment...", progress: 90 },
{ step: "Completing fact-check...", progress: 100 }
];
let currentStepIndex = 0;
// Start progress updates
const progressInterval = setInterval(() => {
if (currentStepIndex < progressSteps.length) {
setFactCheckProgress(progressSteps[currentStepIndex]);
currentStepIndex++;
}
}, 2000); // Update every 2 seconds
// Set a timeout for the fact check (30 seconds)
const timeoutId = setTimeout(() => {
console.log('🔍 [TextSelectionHandler] Fact check timeout reached');
clearInterval(progressInterval);
setFactCheckProgress(null);
setIsFactChecking(false);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
});
}, 30000); // 30 second timeout
try {
console.log('🔍 [TextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
const results = await hallucinationDetectorService.detectHallucinations({
text: text.trim(),
include_sources: true,
max_claims: 10
});
console.log('🔍 [TextSelectionHandler] Fact check results received:', results);
console.log('🔍 [TextSelectionHandler] Results success:', results.success);
console.log('🔍 [TextSelectionHandler] Results claims count:', results.claims?.length || 0);
console.log('🔍 [TextSelectionHandler] Setting factCheckResults state...');
setFactCheckResults(results);
console.log('🔍 [TextSelectionHandler] factCheckResults state set');
} catch (error) {
console.error('🔍 [TextSelectionHandler] Error checking facts:', error);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
});
} finally {
console.log('🔍 [TextSelectionHandler] Fact check completed, setting isFactChecking to false');
clearInterval(progressInterval);
clearTimeout(timeoutId);
setFactCheckProgress(null);
setIsFactChecking(false);
}
};
const handleCloseFactCheckResults = () => {
setFactCheckResults(null);
};
// Quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [TextSelectionHandler] handleQuickEdit called:', editType, selectedText);
let editedText = selectedText;
switch (editType) {
case 'tighten':
// Add hook emoji to the beginning
editedText = selectedText.replace(/^(.*?)([\.!?])?$/, '👉 $1$2');
break;
case 'add-cta':
// Add call-to-action
editedText = selectedText + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
break;
case 'shorten':
// Truncate if longer than 100 characters
editedText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText;
break;
case 'lengthen':
// Add more content
editedText = selectedText + '\n\nThis approach has shown strong results. The key is to maintain consistency while adapting to changing conditions.';
break;
case 'professionalize':
// Add professional prefix
editedText = '[Professionalized]\n\n' + selectedText;
break;
default:
return;
}
// Dispatch event to replace the selected text
window.dispatchEvent(new CustomEvent('linkedinwriter:replaceSelectedText', {
detail: {
originalText: selectedText,
editedText: editedText,
editType: editType
}
}));
// Close the selection menu
setSelectionMenu(null);
};
// Cleanup progress and timeouts on unmount
useEffect(() => {
return () => {
setFactCheckProgress(null);
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
};
}, []);
// Debug: Log selection menu changes
useEffect(() => {
console.log('🔍 [TextSelectionHandler] Selection menu state changed:', selectionMenu);
}, [selectionMenu]);
// Text selection handler with debouncing
const handleTextSelection = () => {
console.log('🔍 [TextSelectionHandler] handleTextSelection called');
// Clear any existing timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Debounce the selection handling
selectionTimeoutRef.current = setTimeout(() => {
try {
const sel = window.getSelection();
console.log('🔍 [TextSelectionHandler] Selection object (debounced):', sel);
if (!sel || sel.rangeCount === 0) {
console.log('🔍 [TextSelectionHandler] No selection or range count is 0');
setSelectionMenu(null);
return;
}
const text = (sel.toString() || '').trim();
console.log('🔍 [TextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
if (!text || text.length < 10) {
console.log('🔍 [TextSelectionHandler] Text too short or empty, hiding menu');
setSelectionMenu(null);
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const container = contentRef.current?.getBoundingClientRect();
console.log('🔍 [TextSelectionHandler] Range rect:', rect, 'Container rect:', container);
if (!container) {
console.log('🔍 [TextSelectionHandler] No container rect, hiding menu');
setSelectionMenu(null);
return;
}
const x = Math.max(8, rect.left - container.left + (rect.width / 2));
const y = Math.max(8, rect.top - container.top);
const menuPosition = { x, y, text };
console.log('🔍 [TextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
setSelectionMenu(menuPosition);
} catch (error) {
console.error('🔍 [TextSelectionHandler] Error handling text selection:', error);
setSelectionMenu(null);
}
}, 150); // 150ms debounce
};
return {
selectionMenu,
setSelectionMenu,
factCheckResults,
isFactChecking,
factCheckProgress,
handleTextSelection,
handleCheckFacts,
handleCloseFactCheckResults,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<>
{/* Text Selection Menu */}
{selectionMenu && (
<div
onClick={(e) => {
console.log('🔍 [TextSelectionHandler] Selection menu clicked!', e.target);
e.stopPropagation();
}}
style={{
position: 'absolute',
top: selectionMenu.y - 40,
left: selectionMenu.x - 200,
background: 'rgba(10, 102, 194, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '8px 12px',
boxShadow: '0 10px 24px rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(10px)',
zIndex: 10000,
minWidth: '200px'
}}
>
{/* Fact Check Button */}
<button
onClick={(e) => {
console.log('🔍 [TextSelectionHandler] Check Facts button clicked!', selectionMenu.text);
e.preventDefault();
e.stopPropagation();
handleCheckFacts(selectionMenu.text);
}}
disabled={isFactChecking}
style={{
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '600',
cursor: isFactChecking ? 'not-allowed' : 'pointer',
opacity: isFactChecking ? 0.6 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '4px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}
}}
onMouseLeave={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}
}}
>
{isFactChecking ? (
<>
<div style={{
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
Checking...
</>
) : (
<>
🔍 Check Facts
</>
)}
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
paddingTop: '8px',
marginTop: '4px'
}}>
<div style={{
color: 'rgba(255, 255, 255, 0.8)',
fontSize: '10px',
fontWeight: '500',
marginBottom: '6px',
textAlign: 'center'
}}>
Quick Edit:
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '4px'
}}>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('tighten', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Tighten
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('add-cta', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Add CTA
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('shorten', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Shorten
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('lengthen', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Lengthen
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('professionalize', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease',
gridColumn: '1 / -1'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Professionalize
</button>
</div>
</div>
{/* Close Button */}
<button
onClick={() => setSelectionMenu(null)}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 12px',
color: 'rgba(255, 255, 255, 0.8)',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease',
width: '100%',
marginTop: '4px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.8)';
}}
>
Close
</button>
</div>
)}
{/* Fact Check Progress Modal */}
{isFactChecking && factCheckProgress && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '32px',
maxWidth: '400px',
width: '90%',
textAlign: 'center',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.15)'
}}
>
<div
style={{
width: '60px',
height: '60px',
border: '4px solid #e3f2fd',
borderTop: '4px solid #1976d2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 24px'
}}
/>
<h3 style={{ margin: '0 0 16px', color: '#1976d2', fontSize: '18px', fontWeight: '600' }}>
Fact-Checking in Progress
</h3>
<p style={{ margin: '0 0 24px', color: '#666', fontSize: '14px', lineHeight: '1.5' }}>
{factCheckProgress.step}
</p>
<div
style={{
width: '100%',
height: '8px',
backgroundColor: '#e3f2fd',
borderRadius: '4px',
overflow: 'hidden',
marginBottom: '16px'
}}
>
<div
style={{
width: `${factCheckProgress.progress}%`,
height: '100%',
backgroundColor: '#1976d2',
borderRadius: '4px',
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
<p style={{ margin: '0', color: '#999', fontSize: '12px' }}>
This may take 10-15 seconds...
</p>
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)}
{/* Fact Check Results Modal */}
{factCheckResults && (
<>
{console.log('🔍 [TextSelectionHandler] Rendering FactCheckResults with:', factCheckResults)}
<FactCheckResults
results={factCheckResults}
onClose={handleCloseFactCheckResults}
/>
</>
)}
</>
)
};
};
export default useTextSelectionHandler;

View File

@@ -0,0 +1,187 @@
import React from 'react';
import { WASuggestion } from '../../services/writingAssistantService';
interface WritingAssistantCardProps {
assistantOn: boolean;
waSuggestion: WASuggestion | null;
waError?: string | null;
showContinuePrompt?: boolean;
draft: string;
onDraftChange: (value: string) => void;
onDismissSuggestion: () => void;
anchor?: { top: number; left: number } | null;
caretIndex?: number;
onInsertAtCaret?: (text: string, caretIndex: number) => void;
onContinueWriting?: () => void;
}
const WritingAssistantCard: React.FC<WritingAssistantCardProps> = ({
assistantOn,
waSuggestion,
waError,
showContinuePrompt,
draft,
onDraftChange,
onDismissSuggestion,
anchor,
caretIndex,
onInsertAtCaret,
onContinueWriting
}) => {
if (!assistantOn || (!waSuggestion && !waError && !showContinuePrompt)) return null;
return (
<div style={{
position: anchor ? 'absolute' : 'sticky',
top: anchor ? `${anchor.top}px` : 0,
left: anchor ? `${anchor.left}px` : undefined,
width: anchor ? 'auto' : '100%',
minWidth: anchor ? '320px' : 'auto',
maxWidth: anchor ? '600px' : '100%',
zIndex: 1000,
background: '#fff',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 12,
marginBottom: anchor ? 0 : 12,
boxShadow: '0 6px 18px rgba(0,0,0,0.12)',
wordWrap: 'break-word',
overflowWrap: 'break-word'
}}>
{waError ? (
// Error state
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ color: '#d32f2f' }}> Assistive Writing Error</strong>
</div>
<div style={{ fontSize: 14, color: '#d32f2f', marginBottom: 8 }}>
{waError}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<button
onClick={onDismissSuggestion}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #d32f2f',
background: '#d32f2f',
color: '#fff',
fontSize: 12,
minWidth: '80px',
whiteSpace: 'nowrap'
}}
>
Dismiss
</button>
</div>
</>
) : showContinuePrompt ? (
// Continue CTA state
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ color: '#0a66c2' }}>Assistive Writing</strong>
</div>
<div style={{ fontSize: 14, color: '#333', marginBottom: 8 }}>
ALwrity can contextually continue writing. Click Continue writing.
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<button
onClick={() => onContinueWriting && onContinueWriting()}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #0a66c2',
background: '#0a66c2',
color: '#fff',
fontSize: 12,
minWidth: '120px',
whiteSpace: 'nowrap'
}}
>
Continue writing
</button>
<button
onClick={onDismissSuggestion}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #ddd',
background: '#fff',
color: '#555',
fontSize: 12,
minWidth: '80px',
whiteSpace: 'nowrap'
}}
>
Dismiss
</button>
</div>
</>
) : waSuggestion ? (
// Suggestion state
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ color: '#0a66c2' }}>Assistive Writing Suggestion</strong>
<span style={{ fontSize: 12, color: '#999' }}>Confidence: {Math.round((waSuggestion.confidence || 0) * 100)}%</span>
</div>
<div style={{ fontSize: 14, color: '#333', marginBottom: 8 }}>
{waSuggestion.text}
</div>
{waSuggestion.sources?.length > 0 && (
<div style={{ fontSize: 12, color: '#666', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{waSuggestion.sources.slice(0, 2).map((s, i) => (
<a key={i} href={s.url} target="_blank" rel="noreferrer" style={{ color: '#0a66c2', textDecoration: 'none' }}>{s.title}</a>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<button
onClick={() => {
if (!waSuggestion) return;
// If we have caret position and insert function, insert at caret
if (typeof caretIndex === 'number' && onInsertAtCaret) {
onInsertAtCaret(waSuggestion.text, caretIndex);
} else {
// Fallback to appending at end
const newDraft = draft.endsWith(' ') ? draft + waSuggestion.text : draft + ' ' + waSuggestion.text;
onDraftChange(newDraft);
}
onDismissSuggestion();
}}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #0a66c2',
background: '#0a66c2',
color: '#fff',
fontSize: 12,
minWidth: '80px',
whiteSpace: 'nowrap'
}}
>
Accept
</button>
<button
onClick={onDismissSuggestion}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #ddd',
background: '#fff',
color: '#555',
fontSize: 12,
minWidth: '80px',
whiteSpace: 'nowrap'
}}
>
Dismiss
</button>
</div>
</>
) : null}
</div>
);
};
export default WritingAssistantCard;

View File

@@ -0,0 +1,8 @@
export { default as CitationHoverHandler } from './CitationHoverHandler';
export { default as useTextSelectionHandler } from './TextSelectionHandler';
export { default as QuickEditToolbar } from './QuickEditToolbar';
export { default as DiffPreviewModal } from './DiffPreviewModal';
export { default as ContentPreviewHeader } from './ContentPreviewHeader';
export { ContentPreviewHeaderWithModals } from './ContentPreviewHeader';
export { default as WritingAssistantCard } from './WritingAssistantCard';
export { default as ContentDisplayArea } from './ContentDisplayArea';

View File

@@ -0,0 +1,243 @@
/**
* Service for calling the hallucination detector API endpoints.
*/
export interface SourceDocument {
title: string;
url: string;
text: string;
published_date?: string;
author?: string;
score: number;
}
export interface Claim {
text: string;
confidence: number;
assessment: 'supported' | 'refuted' | 'insufficient_information';
supporting_sources: SourceDocument[];
refuting_sources: SourceDocument[];
reasoning?: string;
}
export interface HallucinationDetectionRequest {
text: string;
include_sources?: boolean;
max_claims?: number;
}
export interface HallucinationDetectionResponse {
success: boolean;
claims: Claim[];
overall_confidence: number;
total_claims: number;
supported_claims: number;
refuted_claims: number;
insufficient_claims: number;
timestamp: string;
processing_time_ms?: number;
error?: string;
}
export interface ClaimExtractionRequest {
text: string;
max_claims?: number;
}
export interface ClaimExtractionResponse {
success: boolean;
claims: string[];
total_claims: number;
timestamp: string;
error?: string;
}
export interface ClaimVerificationRequest {
claim: string;
include_sources?: boolean;
}
export interface ClaimVerificationResponse {
success: boolean;
claim: Claim;
timestamp: string;
processing_time_ms?: number;
error?: string;
}
export interface HealthCheckResponse {
status: string;
version: string;
exa_api_available: boolean;
openai_api_available: boolean;
timestamp: string;
}
class HallucinationDetectorService {
private baseUrl: string;
constructor() {
// Use environment variable or default to localhost
this.baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
}
/**
* Detect hallucinations in the provided text.
*/
async detectHallucinations(request: HallucinationDetectionRequest): Promise<HallucinationDetectionResponse> {
console.log('🔍 [HallucinationDetectorService] detectHallucinations called with request:', request);
try {
const url = `${this.baseUrl}/api/hallucination-detector/detect`;
console.log('🔍 [HallucinationDetectorService] Making request to:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
console.log('🔍 [HallucinationDetectorService] Response status:', response.status, 'OK:', response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error('🔍 [HallucinationDetectorService] HTTP error response:', errorText);
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
}
const data = await response.json();
console.log('🔍 [HallucinationDetectorService] Response data:', data);
return data;
} catch (error) {
console.error('🔍 [HallucinationDetectorService] Error detecting hallucinations:', error);
return {
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Extract claims from the provided text.
*/
async extractClaims(request: ClaimExtractionRequest): Promise<ClaimExtractionResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error extracting claims:', error);
return {
success: false,
claims: [],
total_claims: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Verify a single claim.
*/
async verifyClaim(request: ClaimVerificationRequest): Promise<ClaimVerificationResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error verifying claim:', error);
return {
success: false,
claim: {
text: request.claim,
confidence: 0,
assessment: 'insufficient_information',
supporting_sources: [],
refuting_sources: [],
reasoning: 'Error during verification'
},
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Check the health of the hallucination detector service.
*/
async healthCheck(): Promise<HealthCheckResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/health`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error checking health:', error);
return {
status: 'unhealthy',
version: '1.0.0',
exa_api_available: false,
openai_api_available: false,
timestamp: new Date().toISOString()
};
}
}
/**
* Get demo information about the API.
*/
async getDemoInfo(): Promise<any> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/demo`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error getting demo info:', error);
return null;
}
}
}
// Export a singleton instance
export const hallucinationDetectorService = new HallucinationDetectorService();
export default hallucinationDetectorService;

View File

@@ -1,4 +1,4 @@
import { apiClient } from '../api/client';
import { apiClient, aiApiClient } from '../api/client';
// LinkedIn-specific enums
export enum LinkedInPostType {
@@ -246,22 +246,22 @@ export const linkedInWriterApi = {
},
async generatePost(request: LinkedInPostRequest): Promise<LinkedInPostResponse> {
const { data } = await apiClient.post('/api/linkedin/generate-post', request);
const { data } = await aiApiClient.post('/api/linkedin/generate-post', request);
return data;
},
async generateArticle(request: LinkedInArticleRequest): Promise<LinkedInArticleResponse> {
const { data } = await apiClient.post('/api/linkedin/generate-article', request);
const { data } = await aiApiClient.post('/api/linkedin/generate-article', request);
return data;
},
async generateCarousel(request: LinkedInCarouselRequest): Promise<LinkedInCarouselResponse> {
const { data } = await apiClient.post('/api/linkedin/generate-carousel', request);
const { data } = await aiApiClient.post('/api/linkedin/generate-carousel', request);
return data;
},
async generateVideoScript(request: LinkedInVideoScriptRequest): Promise<LinkedInVideoScriptResponse> {
const { data } = await apiClient.post('/api/linkedin/generate-video-script', request);
const { data } = await aiApiClient.post('/api/linkedin/generate-video-script', request);
return data;
},

View File

@@ -0,0 +1,45 @@
export interface WASource {
title: string;
url: string;
text?: string;
author?: string;
published_date?: string;
score: number;
}
export interface WASuggestion {
text: string;
confidence: number;
sources: WASource[];
}
export interface WASuggestResponse {
success: boolean;
suggestions: WASuggestion[];
}
class WritingAssistantService {
private baseUrl: string;
constructor() {
this.baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
}
async suggest(text: string): Promise<WASuggestion[]> {
const resp = await fetch(`${this.baseUrl}/api/writing-assistant/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, max_results: 1 })
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`WA HTTP ${resp.status}: ${t}`);
}
const data: WASuggestResponse = await resp.json();
return data.suggestions || [];
}
}
export const writingAssistantService = new WritingAssistantService();
export default writingAssistantService;