feat: LinkedIn LLM alignment - Phase 1-3 complete
Phase 1: Dead Code Cleanup - Remove GeminiGroundedProvider import and property from linkedin_service.py - Remove fallback_provider property (gemini_provider imports) - Fix routers/linkedin.py edit endpoint to use llm_text_gen - Delete dead LinkedInImageEditor class - Remove dead _transform_gemini_sources from content_generator.py Phase 2: Research Infrastructure Alignment - Add user_id to _conduct_research() for pre-flight validation - Add validate_exa_research_operations() before Exa/Tavily calls - Pass user_id to provider.simple_search() for usage tracking - Inject research content into LLM prompts via _build_research_context() - Fix Google engine path to fallback to Exa - Add Exa → Tavily fallback on research failure Phase 3: Cosmetic Cleanup - Rename _generate_prompts_with_gemini → _generate_prompts_with_llm - Rename _build_gemini_prompt → _build_image_prompt - Rename _parse_gemini_response → _parse_llm_response - Remove all Gemini references from LinkedIn code (0 remaining) - Update docstrings and log messages Additional: - Research caching using existing ResearchCache - Shared ExaContentResearchProvider in services/research/ - Persona service uses llm_text_gen instead of gemini_structured_json_response - LinkedInWriter.tsx ChatMessage → ChatMsg type mapping fix - RegisterLinkedInActionsEnhanced.tsx content_format_rules typing fix
This commit is contained in:
@@ -1,22 +1,41 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button, Snackbar, Alert, CircularProgress } from '@mui/material';
|
||||
import { Save as SaveIcon } from '@mui/icons-material';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotReadable, useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import './styles/alwrity-copilot.css';
|
||||
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
||||
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
||||
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
||||
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker } from './components';
|
||||
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker, type ProgressStep } from './components';
|
||||
import { useCopilotActions } from './components/CopilotActions';
|
||||
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
||||
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { saveLinkedInToAssetLibrary } from '../../services/linkedInWriterApi';
|
||||
import { useContentPlanningStore } from '../../stores/contentPlanningStore';
|
||||
import { useWorkflowStore } from '../../stores/workflowStore';
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
// Optional debug flag: set to true to enable verbose logs locally
|
||||
// const DEBUG_LINKEDIN = false;
|
||||
|
||||
const observabilityHooks = {
|
||||
onChatExpanded: () => {
|
||||
console.log('[LinkedIn Writer] Sidebar opened');
|
||||
},
|
||||
onMessageSent: (message: any) => {
|
||||
const text = typeof message === 'string' ? message : (message?.content ?? '');
|
||||
if (text) {
|
||||
console.log('[LinkedIn Writer] User message tracked:', { content_length: text.length });
|
||||
}
|
||||
},
|
||||
onFeedbackGiven: (id: string, type: string) => {
|
||||
console.log('[LinkedIn Writer] Feedback given:', { id, type });
|
||||
}
|
||||
};
|
||||
|
||||
interface LinkedInWriterProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -60,6 +79,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
|
||||
// Setters
|
||||
setDraft,
|
||||
setChatHistory,
|
||||
setIsPreviewing,
|
||||
setLivePreviewHtml,
|
||||
setPendingEdit,
|
||||
@@ -78,7 +98,13 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
// Utilities
|
||||
getHistoryLength,
|
||||
savePreferences,
|
||||
summarizeHistory
|
||||
summarizeHistory,
|
||||
|
||||
// Direct generation methods
|
||||
generatePost,
|
||||
generateArticle,
|
||||
generateCarousel,
|
||||
generateVideoScript
|
||||
} = useLinkedInWriter();
|
||||
|
||||
// Get persona context for enhanced AI assistance
|
||||
@@ -102,6 +128,86 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
getStorageStats
|
||||
} = useCopilotPersistence();
|
||||
|
||||
// Read calendar topic from navigation state (e.g. from Calendar tab)
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { completeTask } = useWorkflowStore();
|
||||
const locationState = location.state as {
|
||||
calendarTopic?: string;
|
||||
calendarDescription?: string;
|
||||
calendarEventId?: string;
|
||||
workflowTaskId?: string;
|
||||
} | null;
|
||||
|
||||
// Pre-fill context from calendar event on mount
|
||||
useEffect(() => {
|
||||
const topic = locationState?.calendarTopic;
|
||||
if (topic) {
|
||||
const description = locationState?.calendarDescription || '';
|
||||
const contextText = `Topic: ${topic}${description ? `\nDescription: ${description}` : ''}`;
|
||||
handleContextChange(contextText);
|
||||
// Clear navigation state so refresh doesn't re-trigger
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Save to Asset Library + Mark Calendar Event Complete ──────
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState<string | null>(null);
|
||||
const { updateEvent } = useContentPlanningStore();
|
||||
|
||||
const handleSaveToAssetLibrary = async () => {
|
||||
if (!draft) return;
|
||||
setSaveStatus('saving');
|
||||
setSaveErrorMessage(null);
|
||||
try {
|
||||
const topic = context?.startsWith('Topic:')
|
||||
? context.replace(/^Topic:\s*/, '').split('\n')[0].trim()
|
||||
: undefined;
|
||||
const title = draft.split('\n')[0].substring(0, 100) || 'LinkedIn Post';
|
||||
|
||||
await saveLinkedInToAssetLibrary({
|
||||
title,
|
||||
content: draft,
|
||||
topic,
|
||||
tags: ['linkedin_post', 'social_media'],
|
||||
assetMetadata: {
|
||||
word_count: draft.split(/\s+/).length,
|
||||
source: locationState?.calendarTopic ? 'calendar' : 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
// Mark the originating calendar event as published
|
||||
if (locationState?.calendarEventId) {
|
||||
try {
|
||||
await updateEvent(locationState.calendarEventId, { status: 'published' });
|
||||
} catch (err) {
|
||||
console.warn('[LinkedInWriter] Failed to update calendar event status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the workflow task as completed (for calendar-sourced tasks)
|
||||
if (locationState?.workflowTaskId) {
|
||||
try {
|
||||
await completeTask(locationState.workflowTaskId);
|
||||
} catch (err) {
|
||||
console.warn('[LinkedInWriter] Failed to complete workflow task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setSaveStatus('saved');
|
||||
|
||||
// Navigate back to dashboard after a brief delay so the user sees "saved"
|
||||
setTimeout(() => navigate('/dashboard'), 1500);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.detail || err?.message || 'Please try again.';
|
||||
console.error('[LinkedInWriter] Save failed:', err);
|
||||
setSaveErrorMessage(message);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Sync component state with enhanced persistence
|
||||
useEffect(() => {
|
||||
console.log('[LinkedIn Writer] Component mounted, enhanced persistence enabled');
|
||||
@@ -110,22 +216,34 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
const loadPersistedData = () => {
|
||||
try {
|
||||
// Load chat history
|
||||
const chatHistory = loadChatHistory();
|
||||
console.log(`📖 Loaded ${chatHistory.length} persisted chat messages`);
|
||||
const persistedChatHistory = loadChatHistory();
|
||||
if (persistedChatHistory.length > 0) {
|
||||
setChatHistory(persistedChatHistory.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
ts: m.timestamp || Date.now(),
|
||||
action: m.metadata?.action,
|
||||
result: m.metadata?.result
|
||||
})));
|
||||
console.log(`📖 Restored ${persistedChatHistory.length} persisted chat messages`);
|
||||
}
|
||||
|
||||
// Load user preferences
|
||||
const persistedPrefs = loadPersistedPreferences();
|
||||
console.log('📖 Loaded persisted user preferences:', persistedPrefs);
|
||||
if (persistedPrefs) {
|
||||
setUserPreferences(persistedPrefs);
|
||||
console.log('📖 Restored persisted user preferences');
|
||||
}
|
||||
|
||||
// Load conversation context
|
||||
// Load conversation context (for future use)
|
||||
const conversationContext = loadConversationContext();
|
||||
console.log('📖 Loaded persisted conversation context:', conversationContext);
|
||||
|
||||
// Load draft content
|
||||
const persistedDraft = loadDraftContent();
|
||||
if (persistedDraft && !draft) {
|
||||
console.log('📖 Restoring persisted draft content');
|
||||
// Note: We'll need to integrate this with the useLinkedInWriter hook
|
||||
setDraft(persistedDraft);
|
||||
console.log('📖 Restored persisted draft content');
|
||||
}
|
||||
|
||||
// Load last session
|
||||
@@ -182,13 +300,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
savePersistedPreferences(prefs);
|
||||
};
|
||||
|
||||
// Share current draft and context with CopilotKit for better context awareness
|
||||
useCopilotReadable({
|
||||
description: 'Current LinkedIn content draft the user is editing',
|
||||
value: draft,
|
||||
categories: ['social', 'linkedin', 'draft']
|
||||
});
|
||||
|
||||
// Auto-save draft content when it changes
|
||||
useEffect(() => {
|
||||
if (draft && draft.trim().length > 0) {
|
||||
@@ -196,12 +307,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
}
|
||||
}, [draft, saveDraftContent]);
|
||||
|
||||
useCopilotReadable({
|
||||
description: 'User context and notes for LinkedIn content',
|
||||
value: context,
|
||||
categories: ['social', 'linkedin', 'context']
|
||||
});
|
||||
|
||||
// Allow Copilot to update the draft directly
|
||||
useCopilotActionTyped({
|
||||
name: 'updateLinkedInDraft',
|
||||
@@ -239,6 +344,81 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
setDraft
|
||||
});
|
||||
|
||||
const labels = useMemo(() => ({
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: draft
|
||||
? '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!`
|
||||
}), [draft, corePersona]);
|
||||
|
||||
const makeSystemMessage = useCallback((context: string, additional?: string) => {
|
||||
const prefs = userPreferences;
|
||||
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
||||
const history = summarizeHistory();
|
||||
const historyLine = history ? `Recent conversation (last 15 messages):\n${history}` : '';
|
||||
const currentDraft = draft ? `Current draft content:\n${draft}` : 'No current draft content.';
|
||||
const tone = prefs.tone || 'professional';
|
||||
const industry = prefs.industry || 'Technology';
|
||||
const audience = prefs.target_audience || 'professionals';
|
||||
|
||||
const personaGuidance = corePersona && platformPersona ? `
|
||||
PERSONA-AWARE WRITING GUIDANCE:
|
||||
- PERSONA: ${corePersona.persona_name} (${corePersona.archetype})
|
||||
- CORE BELIEF: ${corePersona.core_belief}
|
||||
- CONFIDENCE SCORE: ${corePersona.confidence_score}%
|
||||
- LINGUISTIC STYLE: ${corePersona.linguistic_fingerprint?.sentence_metrics?.average_sentence_length_words || 'Unknown'} words average, ${corePersona.linguistic_fingerprint?.sentence_metrics?.active_to_passive_ratio || 'Unknown'} active/passive ratio
|
||||
- GO-TO WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.go_to_words?.join(', ') || 'None specified'}
|
||||
- AVOID WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.avoid_words?.join(', ') || 'None specified'}
|
||||
|
||||
PLATFORM OPTIMIZATION (LinkedIn):
|
||||
- CHARACTER LIMIT: ${platformPersona.content_format_rules?.character_limit || '3000'} characters
|
||||
- OPTIMAL LENGTH: ${platformPersona.content_format_rules?.optimal_length || '150-300 words'}
|
||||
- ENGAGEMENT PATTERN: ${platformPersona.engagement_patterns?.posting_frequency || '2-3 times per week'}
|
||||
- HASHTAG STRATEGY: ${platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'}
|
||||
|
||||
ALWAYS generate content that matches this persona's linguistic fingerprint and platform optimization rules.` : '';
|
||||
|
||||
const guidance = `
|
||||
You are ALwrity's LinkedIn Writing Assistant specializing in ${industry} content.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- TONE: Always maintain a ${tone} tone throughout all content
|
||||
- INDUSTRY: Focus specifically on ${industry} industry context and terminology
|
||||
- AUDIENCE: Target content specifically for ${audience}
|
||||
- QUALITY: Ensure all content meets LinkedIn professional standards
|
||||
${personaGuidance ? `\n${personaGuidance}` : ''}
|
||||
|
||||
CURRENT CONTEXT:
|
||||
${currentDraft}
|
||||
|
||||
Available LinkedIn content tools:
|
||||
- generateLinkedInPost: Create ${tone} LinkedIn posts for ${industry} ${audience}
|
||||
- generateLinkedInArticle: Write ${tone} thought leadership articles about ${industry}
|
||||
- generateLinkedInCarousel: Design ${tone} multi-slide carousels for ${industry} insights
|
||||
- generateLinkedInVideoScript: Create ${tone} video scripts for ${industry} topics
|
||||
- generateLinkedInCommentResponse: Draft ${tone} responses appropriate for ${industry}
|
||||
|
||||
🎭 ENHANCED PERSONA-AWARE ACTIONS (Recommended):
|
||||
- generateLinkedInPostWithPersona: Create posts optimized for your writing style and platform constraints
|
||||
- generateLinkedInArticleWithPersona: Write articles with persona-aware optimization
|
||||
- validateContentAgainstPersona: Validate existing content against your persona
|
||||
- getPersonaWritingSuggestions: Get personalized writing recommendations
|
||||
|
||||
DIRECT DRAFT ACTIONS:
|
||||
- updateLinkedInDraft: Replace the entire draft with new content
|
||||
- appendToLinkedInDraft: Add text to the existing draft
|
||||
- editLinkedInDraft: Apply quick edits (Casual, Professional, TightenHook, AddCTA, Shorten, Lengthen) to the current draft
|
||||
|
||||
IMPORTANT: When refining or editing content, always reference the current draft above. If the user asks to refine their post, use the current draft content as the starting point. Never ask for content that already exists in the draft.
|
||||
|
||||
For quick edits, use editLinkedInDraft with the appropriate operation. This will show a live preview of changes before applying them.
|
||||
|
||||
Use user preferences, context, conversation history, and persona data to personalize all content.
|
||||
Always respect the user's preferred ${tone} tone, ${industry} industry focus, and writing persona style.
|
||||
Always use the most appropriate tool for the user's request.`.trim();
|
||||
return [prefsLine, historyLine, currentDraft, guidance, additional].filter(Boolean).join('\n\n');
|
||||
}, [draft, userPreferences, corePersona, platformPersona, summarizeHistory]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`linkedin-writer ${className}`}
|
||||
@@ -269,7 +449,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
height: progressActive || progressSteps.length > 0 ? 'auto' : 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<ProgressTracker steps={progressSteps as any} active={progressActive} />
|
||||
<ProgressTracker steps={progressSteps as ProgressStep[]} active={progressActive} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -286,7 +466,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
currentAction={currentAction}
|
||||
/>
|
||||
|
||||
{/* Content Area */}
|
||||
{/* Content Area */}
|
||||
{draft || isGenerating ? (<>
|
||||
{/* Editor Panel - Show when there's content or generating */}
|
||||
<ContentEditor
|
||||
@@ -309,17 +489,57 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
onPreviewToggle={handlePreviewToggle}
|
||||
topic={context ? context.split('\n')[0].substring(0, 50) : undefined}
|
||||
/>
|
||||
|
||||
|
||||
{/* Save to Asset Library button - only when there's generated content */}
|
||||
{draft && !isGenerating && (
|
||||
<div style={{ padding: '8px 24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={saveStatus === 'saving' ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
|
||||
onClick={handleSaveToAssetLibrary}
|
||||
disabled={saveStatus === 'saving' || saveStatus === 'saved'}
|
||||
size="small"
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Saving...' :
|
||||
saveStatus === 'saved' ? 'Saved ✓' :
|
||||
'Save to Asset Library'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>) : (
|
||||
/* Welcome Message - Show when no content */
|
||||
<WelcomeMessage
|
||||
draft={draft}
|
||||
isGenerating={isGenerating}
|
||||
onGeneratePost={generatePost}
|
||||
onGenerateArticle={generateArticle}
|
||||
onGenerateCarousel={generateCarousel}
|
||||
onGenerateVideoScript={generateVideoScript}
|
||||
userPreferences={userPreferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save feedback snackbar */}
|
||||
<Snackbar
|
||||
open={saveStatus === 'saved' || saveStatus === 'error'}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => { setSaveStatus('idle'); setSaveErrorMessage(null); }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={saveStatus === 'saved' ? 'success' : 'error'}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{saveStatus === 'saved'
|
||||
? 'LinkedIn post saved to Asset Library!'
|
||||
: `Failed to save: ${saveErrorMessage || 'Please try again.'}`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{/* Register CopilotKit Actions */}
|
||||
<RegisterLinkedInActions />
|
||||
<RegisterLinkedInEditActions />
|
||||
@@ -330,95 +550,10 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
{/* CopilotKit Sidebar */}
|
||||
<CopilotSidebar
|
||||
className="alwrity-copilot-sidebar linkedin-writer"
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: draft ?
|
||||
'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!`
|
||||
}}
|
||||
labels={labels}
|
||||
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)}` : '';
|
||||
const history = summarizeHistory();
|
||||
const historyLine = history ? `Recent conversation (last 15 messages):\n${history}` : '';
|
||||
const currentDraft = draft ? `Current draft content:\n${draft}` : 'No current draft content.';
|
||||
const tone = prefs.tone || 'professional';
|
||||
const industry = prefs.industry || 'Technology';
|
||||
const audience = prefs.target_audience || 'professionals';
|
||||
|
||||
// Enhanced persona-aware guidance
|
||||
const personaGuidance = corePersona && platformPersona ? `
|
||||
PERSONA-AWARE WRITING GUIDANCE:
|
||||
- PERSONA: ${corePersona.persona_name} (${corePersona.archetype})
|
||||
- CORE BELIEF: ${corePersona.core_belief}
|
||||
- CONFIDENCE SCORE: ${corePersona.confidence_score}%
|
||||
- LINGUISTIC STYLE: ${corePersona.linguistic_fingerprint?.sentence_metrics?.average_sentence_length_words || 'Unknown'} words average, ${corePersona.linguistic_fingerprint?.sentence_metrics?.active_to_passive_ratio || 'Unknown'} active/passive ratio
|
||||
- GO-TO WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.go_to_words?.join(', ') || 'None specified'}
|
||||
- AVOID WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.avoid_words?.join(', ') || 'None specified'}
|
||||
|
||||
PLATFORM OPTIMIZATION (LinkedIn):
|
||||
- CHARACTER LIMIT: ${platformPersona.content_format_rules?.character_limit || '3000'} characters
|
||||
- OPTIMAL LENGTH: ${platformPersona.content_format_rules?.optimal_length || '150-300 words'}
|
||||
- ENGAGEMENT PATTERN: ${platformPersona.engagement_patterns?.posting_frequency || '2-3 times per week'}
|
||||
- HASHTAG STRATEGY: ${platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'}
|
||||
|
||||
ALWAYS generate content that matches this persona's linguistic fingerprint and platform optimization rules.` : '';
|
||||
|
||||
const guidance = `
|
||||
You are ALwrity's LinkedIn Writing Assistant specializing in ${industry} content.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- TONE: Always maintain a ${tone} tone throughout all content
|
||||
- INDUSTRY: Focus specifically on ${industry} industry context and terminology
|
||||
- AUDIENCE: Target content specifically for ${audience}
|
||||
- QUALITY: Ensure all content meets LinkedIn professional standards
|
||||
${personaGuidance ? `\n${personaGuidance}` : ''}
|
||||
|
||||
CURRENT CONTEXT:
|
||||
${currentDraft}
|
||||
|
||||
Available LinkedIn content tools:
|
||||
- generateLinkedInPost: Create ${tone} LinkedIn posts for ${industry} ${audience}
|
||||
- generateLinkedInArticle: Write ${tone} thought leadership articles about ${industry}
|
||||
- generateLinkedInCarousel: Design ${tone} multi-slide carousels for ${industry} insights
|
||||
- generateLinkedInVideoScript: Create ${tone} video scripts for ${industry} topics
|
||||
- generateLinkedInCommentResponse: Draft ${tone} responses appropriate for ${industry}
|
||||
|
||||
🎭 ENHANCED PERSONA-AWARE ACTIONS (Recommended):
|
||||
- generateLinkedInPostWithPersona: Create posts optimized for your writing style and platform constraints
|
||||
- generateLinkedInArticleWithPersona: Write articles with persona-aware optimization
|
||||
- validateContentAgainstPersona: Validate existing content against your persona
|
||||
- getPersonaWritingSuggestions: Get personalized writing recommendations
|
||||
|
||||
DIRECT DRAFT ACTIONS:
|
||||
- updateLinkedInDraft: Replace the entire draft with new content
|
||||
- appendToLinkedInDraft: Add text to the existing draft
|
||||
- editLinkedInDraft: Apply quick edits (Casual, Professional, TightenHook, AddCTA, Shorten, Lengthen) to the current draft
|
||||
|
||||
IMPORTANT: When refining or editing content, always reference the current draft above. If the user asks to refine their post, use the current draft content as the starting point. Never ask for content that already exists in the draft.
|
||||
|
||||
For quick edits, use editLinkedInDraft with the appropriate operation. This will show a live preview of changes before applying them.
|
||||
|
||||
Use user preferences, context, conversation history, and persona data to personalize all content.
|
||||
Always respect the user's preferred ${tone} tone, ${industry} industry focus, and writing persona style.
|
||||
Always use the most appropriate tool for the user's request.`.trim();
|
||||
return [prefsLine, historyLine, currentDraft, guidance, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
observabilityHooks={{
|
||||
onChatExpanded: () => {
|
||||
console.log('[LinkedIn Writer] Sidebar opened');
|
||||
},
|
||||
onMessageSent: (message: any) => {
|
||||
const text = typeof message === 'string' ? message : (message?.content ?? '');
|
||||
if (text) {
|
||||
console.log('[LinkedIn Writer] User message tracked:', { content_length: text.length });
|
||||
}
|
||||
},
|
||||
onFeedbackGiven: (id: string, type: string) => {
|
||||
console.log('[LinkedIn Writer] Feedback given:', { id, type });
|
||||
}
|
||||
}}
|
||||
makeSystemMessage={makeSystemMessage}
|
||||
observabilityHooks={observabilityHooks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||
import {
|
||||
mapPostType,
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
readPrefs
|
||||
} from './utils/linkedInWriterUtils';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
const RegisterLinkedInActions: React.FC = () => {
|
||||
// LinkedIn Image Generation Actions
|
||||
@@ -53,7 +52,12 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', required: true, description: 'The image generation prompt' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style', attributes: [
|
||||
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||
{ name: 'style', type: 'string', required: true, description: 'Writing style/tone' }
|
||||
] },
|
||||
{ name: 'aspect_ratio', type: 'string', required: false, description: 'Image aspect ratio (default: 1:1)' }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
@@ -88,6 +92,54 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Image Editing Action
|
||||
useCopilotActionTyped({
|
||||
name: 'editLinkedInImage',
|
||||
description: 'Edit an existing LinkedIn image using natural language (change style, background, colors, etc.). Requires an image_id from a previously generated LinkedIn image.',
|
||||
parameters: [
|
||||
{ name: 'image_id', type: 'string', required: true, description: 'ID of the previously generated LinkedIn image to edit' },
|
||||
{ name: 'prompt', type: 'string', required: true, description: 'Natural language description of desired edits (e.g., "Make the background blue", "Add more professional look")' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type', attributes: [
|
||||
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||
] },
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/linkedin/edit-image', {
|
||||
image_id: args.image_id,
|
||||
prompt: args.prompt,
|
||||
content_context: args.content_context,
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
image_data: result.image_data,
|
||||
image_id: result.image_id,
|
||||
image_url: result.image_url,
|
||||
message: result.image_id
|
||||
? `✅ LinkedIn image edited successfully! Your edited image (ID: ${result.image_id}) is ready to use.`
|
||||
: `✅ LinkedIn image edited successfully! The image is ready to use in your content.`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Image editing failed'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing image:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to edit image. Please try again.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Post Generation
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInPost',
|
||||
@@ -468,7 +520,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
parameters: [
|
||||
{ name: 'topic', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'slide_count', type: 'number', required: false }
|
||||
{ name: 'number_of_slides', type: 'number', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const prefs = readPrefs();
|
||||
@@ -499,7 +551,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
const res = await linkedInWriterApi.generateCarousel({
|
||||
topic: args?.topic || prefs.topic || 'Professional development tips',
|
||||
industry: mapIndustry(args?.industry || prefs.industry),
|
||||
slide_count: args?.slide_count || prefs.slide_count || 8,
|
||||
number_of_slides: args?.number_of_slides || prefs.number_of_slides || 8,
|
||||
tone: mapTone(args?.tone || prefs.tone),
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||
key_takeaways: args?.key_takeaways || prefs.key_takeaways || [],
|
||||
@@ -898,7 +950,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Profile Optimization
|
||||
// LinkedIn Profile Optimization (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeLinkedInProfile',
|
||||
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
||||
@@ -907,29 +959,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'experience_level', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.optimizeProfile({
|
||||
current_headline: args?.current_headline || 'Professional',
|
||||
industry: mapIndustry(args?.industry),
|
||||
experience_level: args?.experience_level || 'mid-level',
|
||||
target_role: args?.target_role,
|
||||
key_skills: args?.key_skills || []
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
let content = `# LinkedIn Profile Optimization\n\n`;
|
||||
content += `## Optimized Headline\n${res.data.headline}\n\n`;
|
||||
content += `## About Section\n${res.data.about}\n\n`;
|
||||
content += `## Key Skills\n${res.data.skills?.join(', ')}\n\n`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to optimize LinkedIn profile' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Profile Optimization is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Poll Generation
|
||||
// LinkedIn Poll Generation (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInPoll',
|
||||
description: 'Generate an engaging LinkedIn poll with professional questions',
|
||||
@@ -938,31 +974,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'poll_type', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.generatePoll({
|
||||
topic: args?.topic || 'Professional development',
|
||||
industry: mapIndustry(args?.industry),
|
||||
poll_type: args?.poll_type || 'professional',
|
||||
target_audience: args?.target_audience || 'Industry professionals',
|
||||
question_count: args?.question_count || 1
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
let content = `# LinkedIn Poll: ${res.data.question}\n\n`;
|
||||
content += `## Options\n`;
|
||||
res.data.options?.forEach((option: string, index: number) => {
|
||||
content += `${index + 1}. ${option}\n`;
|
||||
});
|
||||
content += `\n## Context\n${res.data.context || ''}\n\n`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to generate LinkedIn poll' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Poll Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Company Update Generation
|
||||
// LinkedIn Company Update Generation (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInCompanyUpdate',
|
||||
description: 'Generate a professional company update for LinkedIn',
|
||||
@@ -971,22 +989,9 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'update_type', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.generateCompanyUpdate({
|
||||
company_name: args?.company_name || 'Your Company',
|
||||
update_type: args?.update_type || 'achievement',
|
||||
industry: mapIndustry(args?.industry),
|
||||
announcement: args?.announcement,
|
||||
target_audience: args?.target_audience || 'Industry professionals and clients',
|
||||
include_metrics: args?.include_metrics ?? true
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
const content = res.data.content;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to generate LinkedIn company update' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Company Update Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||
import {
|
||||
mapPostType,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
readPrefs
|
||||
} from './utils/linkedInWriterUtils';
|
||||
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
// Get persona context for enhanced content generation
|
||||
@@ -102,9 +100,8 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
}
|
||||
|
||||
// Apply persona constraints to parameters
|
||||
const personaConstraints = platformPersona?.content_format_rules as any || {};
|
||||
const maxLength = personaConstraints.character_limit || prefs.max_length || 2000;
|
||||
const optimalLength = personaConstraints.optimal_length || '150-300 words';
|
||||
const maxLength = platformPersona?.content_format_rules?.character_limit || prefs.max_length || 2000;
|
||||
const optimalLength = platformPersona?.content_format_rules?.optimal_length || '150-300 words';
|
||||
|
||||
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
||||
|
||||
@@ -329,9 +326,11 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
}
|
||||
}));
|
||||
|
||||
// Continue with article generation...
|
||||
// (Implementation would continue similar to the post generation)
|
||||
|
||||
// Complete progress and end loading
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Article generation placeholder' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ LinkedIn article generation started with persona optimization!`,
|
||||
@@ -373,7 +372,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
},
|
||||
platform_compliance: {
|
||||
character_count: content.length,
|
||||
optimal_range: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
||||
optimal_range: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||
status: 'analyzing',
|
||||
suggestions: [] as string[]
|
||||
}
|
||||
@@ -401,7 +400,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
});
|
||||
|
||||
// Platform compliance check
|
||||
const charLimit = (platformPersona.content_format_rules as any)?.character_limit || 3000;
|
||||
const charLimit = platformPersona.content_format_rules?.character_limit || 3000;
|
||||
if (content.length > charLimit) {
|
||||
validation.platform_compliance.status = 'exceeds_limit';
|
||||
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
||||
@@ -445,13 +444,13 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
const suggestions = {
|
||||
writing_style: {
|
||||
sentence_structure: corePersona.linguistic_fingerprint?.sentence_metrics?.preferred_sentence_type || 'balanced',
|
||||
tone_recommendation: (corePersona as any).tonal_range?.default_tone || 'professional_friendly',
|
||||
tone_recommendation: platformPersona?.tonal_range?.default_tone || 'professional_friendly',
|
||||
vocabulary_level: corePersona.linguistic_fingerprint?.lexical_features?.vocabulary_level || 'professional'
|
||||
},
|
||||
platform_optimization: {
|
||||
character_limit: (platformPersona.content_format_rules as any)?.character_limit || 3000,
|
||||
optimal_length: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
||||
hashtag_strategy: (platformPersona.lexical_features as any)?.hashtag_strategy || '3-5 relevant hashtags'
|
||||
character_limit: platformPersona.content_format_rules?.character_limit || 3000,
|
||||
optimal_length: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||
hashtag_strategy: platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'
|
||||
},
|
||||
persona_specific: {
|
||||
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
||||
|
||||
@@ -1,160 +1,220 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
import { linkedInWriterApi } from '../../services/linkedInWriterApi';
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
function extractHashtags(text: string): string[] {
|
||||
return text.match(/#[A-Za-z0-9_]+/g) || [];
|
||||
}
|
||||
|
||||
function stripHashtags(text: string): string {
|
||||
return text.replace(/#[A-Za-z0-9_]+\s*/g, '').trim();
|
||||
}
|
||||
|
||||
const RegisterLinkedInEditActions: React.FC = () => {
|
||||
// Professionalize Content
|
||||
// ── 1. Professionalize ────────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'professionalizeLinkedInContent',
|
||||
description: 'Make LinkedIn content more professional and industry-appropriate',
|
||||
description: 'Make LinkedIn content more professional, polished, and industry-appropriate using AI',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
// This would integrate with a backend endpoint for content professionalization
|
||||
const content = args?.content || '';
|
||||
const industry = args?.industry || 'Technology';
|
||||
const targetAudience = args?.target_audience || 'Professionals';
|
||||
|
||||
// For now, return a placeholder response
|
||||
const professionalizedContent = `[Professionalized version of your content for ${industry} industry targeting ${targetAudience}]\n\n${content}`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: professionalizedContent } }));
|
||||
return { success: true, content: professionalizedContent };
|
||||
if (!content.trim()) return { success: false, message: 'No content to professionalize' };
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'professionalize',
|
||||
industry: args?.industry,
|
||||
target_audience: args?.target_audience,
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: 'Content professionalized with AI.' };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to professionalize content' };
|
||||
}
|
||||
});
|
||||
|
||||
// Optimize for Engagement
|
||||
// ── 2. Optimize Engagement ────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeLinkedInEngagement',
|
||||
description: 'Optimize LinkedIn content for better engagement and reach',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'content_type', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
const contentType = args?.content_type || 'post';
|
||||
|
||||
// Placeholder for engagement optimization
|
||||
const optimizedContent = `[Engagement-optimized ${contentType}]\n\n${content}\n\n#ProfessionalDevelopment #Networking #IndustryInsights`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: optimizedContent } }));
|
||||
return { success: true, content: optimizedContent };
|
||||
}
|
||||
});
|
||||
|
||||
// Add Professional Hashtags
|
||||
useCopilotActionTyped({
|
||||
name: 'addLinkedInHashtags',
|
||||
description: 'Add relevant professional hashtags to LinkedIn content',
|
||||
description: 'Optimize LinkedIn content for better engagement — strengthen hook, improve readability, encourage interaction',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
|
||||
// Placeholder for hashtag addition
|
||||
const hashtags = '#ProfessionalDevelopment #Networking #IndustryInsights #CareerGrowth';
|
||||
const contentWithHashtags = `${content}\n\n${hashtags}`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithHashtags } }));
|
||||
return { success: true, content: contentWithHashtags };
|
||||
if (!content.trim()) return { success: false, message: 'No content to optimize' };
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'optimize_engagement',
|
||||
industry: args?.industry,
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: 'Content optimized for engagement.' };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to optimize content' };
|
||||
}
|
||||
});
|
||||
|
||||
// Adjust Tone
|
||||
// ── 3. Add Hashtags (AI-powered) ──────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'adjustLinkedInTone',
|
||||
description: 'Adjust the tone of LinkedIn content to be more professional, conversational, or authoritative',
|
||||
name: 'addLinkedInHashtags',
|
||||
description: 'Generate relevant, industry-specific hashtags for LinkedIn content using AI',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'target_tone', type: 'string', required: false }
|
||||
{ name: 'industry', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
if (!content.trim()) return { success: false, message: 'No content to add hashtags to' };
|
||||
|
||||
const existingHashtags = extractHashtags(content);
|
||||
if (existingHashtags.length >= 5) {
|
||||
showToastNotification('Content already has plenty of hashtags.', 'info');
|
||||
return { success: false, message: 'Content already has 5+ hashtags' };
|
||||
}
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content: stripHashtags(content),
|
||||
edit_type: 'add_hashtags',
|
||||
industry: args?.industry,
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
const newHashtags = extractHashtags(res.content);
|
||||
return { success: true, content: res.content, hashtags: newHashtags };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to generate hashtags' };
|
||||
}
|
||||
});
|
||||
|
||||
// ── 4. Adjust Tone ────────────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'adjustLinkedInTone',
|
||||
description: 'Rewrite LinkedIn content in a different tone — professional, conversational, authoritative, educational, or friendly',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'target_tone', type: 'string', required: false, description: 'professional, conversational, authoritative, educational, friendly' }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
const targetTone = args?.target_tone || 'professional';
|
||||
|
||||
// Placeholder for tone adjustment
|
||||
const adjustedContent = `[Content adjusted to ${targetTone} tone]\n\n${content}`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: adjustedContent } }));
|
||||
return { success: true, content: adjustedContent };
|
||||
if (!content.trim()) return { success: false, message: 'No content to adjust tone for' };
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'adjust_tone',
|
||||
tone: targetTone,
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: `Tone adjusted to ${targetTone}.` };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to adjust tone' };
|
||||
}
|
||||
});
|
||||
|
||||
// Expand Content
|
||||
// ── 5. Expand Content ─────────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'expandLinkedInContent',
|
||||
description: 'Expand LinkedIn content with more details and insights',
|
||||
description: 'Expand LinkedIn content with more depth, examples, data points, and actionable insights using AI',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'expansion_type', type: 'string', required: false }
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
const expansionType = args?.expansion_type || 'insights';
|
||||
|
||||
// Placeholder for content expansion
|
||||
const expandedContent = `${content}\n\n[Additional ${expansionType} and context added here]`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: expandedContent } }));
|
||||
return { success: true, content: expandedContent };
|
||||
if (!content.trim()) return { success: false, message: 'No content to expand' };
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'expand',
|
||||
industry: args?.industry,
|
||||
target_audience: args?.target_audience,
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: 'Content expanded with AI.' };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to expand content' };
|
||||
}
|
||||
});
|
||||
|
||||
// Condense Content
|
||||
// ── 6. Condense Content ───────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'condenseLinkedInContent',
|
||||
description: 'Condense LinkedIn content to be more concise and impactful',
|
||||
description: 'Condense LinkedIn content to be more concise and impactful using AI — preserves key messages',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'target_length', type: 'string', required: false }
|
||||
{ name: 'target_length', type: 'string', required: false, description: 'short, medium, long' }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
const targetLength = args?.target_length || 'short';
|
||||
|
||||
// Placeholder for content condensation
|
||||
const condensedContent = `[Condensed to ${targetLength} format]\n\n${content.substring(0, Math.min(content.length, 500))}...`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: condensedContent } }));
|
||||
return { success: true, content: condensedContent };
|
||||
const targetLength = args?.target_length || 'medium';
|
||||
if (!content.trim()) return { success: false, message: 'No content to condense' };
|
||||
|
||||
const lengthMap: Record<string, string> = { short: 'very concise (1-2 sentences)', medium: 'half the original length', long: 'slightly shortened' };
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'condense',
|
||||
parameters: { target_length: lengthMap[targetLength] || lengthMap.medium },
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: 'Content condensed with AI.' };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to condense content' };
|
||||
}
|
||||
});
|
||||
|
||||
// Add Call to Action
|
||||
// ── 7. Add Call to Action ─────────────────────────────────────────────
|
||||
useCopilotActionTyped({
|
||||
name: 'addLinkedInCallToAction',
|
||||
description: 'Add a professional call to action to LinkedIn content',
|
||||
description: 'Add a contextual, engaging call-to-action to LinkedIn content using AI',
|
||||
parameters: [
|
||||
{ name: 'content', type: 'string', required: false },
|
||||
{ name: 'cta_type', type: 'string', required: false }
|
||||
{ name: 'cta_type', type: 'string', required: false, description: 'engagement, networking, learning, collaboration' }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const content = args?.content || '';
|
||||
const ctaType = args?.cta_type || 'engagement';
|
||||
|
||||
const ctaOptions = {
|
||||
engagement: 'What are your thoughts on this? Share your experience in the comments below!',
|
||||
networking: 'Let\'s connect if you\'re interested in discussing this further.',
|
||||
learning: 'Would you like to learn more about this topic? Drop a comment or DM me.',
|
||||
collaboration: 'Interested in collaborating on similar projects? Let\'s connect!'
|
||||
};
|
||||
|
||||
const cta = ctaOptions[ctaType as keyof typeof ctaOptions] || ctaOptions.engagement;
|
||||
const contentWithCTA = `${content}\n\n${cta}`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithCTA } }));
|
||||
return { success: true, content: contentWithCTA };
|
||||
if (!content.trim()) return { success: false, message: 'No content to add CTA to' };
|
||||
|
||||
if (/\b(call now|sign up|join|try|learn more|comment|share|connect|message|dm|reach out)\b/i.test(content)) {
|
||||
showToastNotification('Content already contains a call to action.', 'info');
|
||||
return { success: false, message: 'Content already has a CTA' };
|
||||
}
|
||||
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'add_cta',
|
||||
parameters: { cta_type: args?.cta_type || 'engagement' },
|
||||
});
|
||||
|
||||
if (res.success && res.content) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||
return { success: true, content: res.content, message: 'CTA added with AI.' };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to add CTA' };
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterLinkedInEditActions;
|
||||
export default RegisterLinkedInEditActions;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import '../../../types/linkedinWriterEvents';
|
||||
|
||||
// Define the cache data type
|
||||
interface BrainstormCacheData {
|
||||
@@ -118,7 +119,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
const handler = async (ev: any) => {
|
||||
try {
|
||||
// Store the event for refresh functionality
|
||||
(window as any).lastBrainstormEvent = ev;
|
||||
window.lastBrainstormEvent = ev;
|
||||
|
||||
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
||||
const finalSeed = ideaSeed || prompt;
|
||||
@@ -239,8 +240,8 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
setBrainstormVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
||||
|
||||
return (
|
||||
@@ -275,7 +276,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
<button
|
||||
onClick={() => {
|
||||
// Force refresh by clearing cache and re-running
|
||||
const { prompt, seed: ideaSeed } = (window as any).lastBrainstormEvent?.detail || {};
|
||||
const { prompt, seed: ideaSeed } = window.lastBrainstormEvent?.detail || {};
|
||||
if (prompt || ideaSeed) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
||||
|
||||
@@ -23,7 +23,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
target_audience: args.target_audience ?? prefs.target_audience ?? '',
|
||||
tone: args.tone ?? prefs.tone ?? 'professional',
|
||||
industry: args.industry ?? prefs.industry ?? 'technology',
|
||||
slide_count: args.slide_count ?? (prefs.slide_count ?? 5),
|
||||
number_of_slides: args.number_of_slides ?? (prefs.number_of_slides ?? 5),
|
||||
key_takeaways: args.key_takeaways ?? (prefs.key_takeaways ?? []),
|
||||
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||
include_cta_slide: args.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||
@@ -40,7 +40,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: {
|
||||
action: 'Generating LinkedIn Carousel',
|
||||
message: `Creating a ${form.slide_count}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
||||
message: `Creating a ${form.number_of_slides}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -59,7 +59,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
target_audience: form.target_audience,
|
||||
tone: mapTone(form.tone),
|
||||
industry: mapIndustry(form.industry),
|
||||
slide_count: form.slide_count,
|
||||
number_of_slides: form.number_of_slides,
|
||||
key_takeaways: form.key_takeaways,
|
||||
include_cover_slide: form.include_cover_slide,
|
||||
include_cta_slide: form.include_cta_slide,
|
||||
@@ -73,7 +73,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
tone: form.tone,
|
||||
industry: form.industry,
|
||||
target_audience: form.target_audience,
|
||||
slide_count: form.slide_count,
|
||||
number_of_slides: form.number_of_slides,
|
||||
key_takeaways: form.key_takeaways,
|
||||
include_cover_slide: form.include_cover_slide,
|
||||
include_cta_slide: form.include_cta_slide,
|
||||
@@ -100,7 +100,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
success: true,
|
||||
carousel_content: content,
|
||||
title: res.data.title,
|
||||
slide_count: res.data.slides.length
|
||||
number_of_slides: res.data.slides.length
|
||||
});
|
||||
} else {
|
||||
throw new Error('No data received from API');
|
||||
@@ -183,11 +183,11 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="slide_count">Number of Slides</label>
|
||||
<label htmlFor="number_of_slides">Number of Slides</label>
|
||||
<select
|
||||
id="slide_count"
|
||||
value={form.slide_count}
|
||||
onChange={(e) => setForm({ ...form, slide_count: parseInt(e.target.value) })}
|
||||
id="number_of_slides"
|
||||
value={form.number_of_slides}
|
||||
onChange={(e) => setForm({ ...form, number_of_slides: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={3}>3 slides (Quick overview)</option>
|
||||
<option value={5}>5 slides (Standard)</option>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
||||
import { useCopilotContext } from '@copilotkit/react-core';
|
||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { showToastNotification } from '../../../utils/toastNotifications';
|
||||
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||
import '../../../types/linkedinWriterEvents';
|
||||
|
||||
// Optional debug flag: set to true to enable verbose logs locally
|
||||
const DEBUG_LINKEDIN = false;
|
||||
@@ -66,9 +67,9 @@ export const useCopilotActions = ({
|
||||
if (copilotContext && typeof copilotContext === 'object') {
|
||||
try {
|
||||
// Check if context has any message sending capabilities
|
||||
if ('sendMessage' in copilotContext && typeof copilotContext.sendMessage === 'function') {
|
||||
if ('sendMessage' in copilotContext && typeof (copilotContext as Record<string, unknown>).sendMessage === 'function') {
|
||||
setTimeout(() => {
|
||||
(copilotContext as any).sendMessage(prompt);
|
||||
(copilotContext as { sendMessage: (msg: string) => void }).sendMessage(prompt);
|
||||
console.log('Message sent via context');
|
||||
return;
|
||||
}, 500);
|
||||
@@ -85,7 +86,7 @@ export const useCopilotActions = ({
|
||||
document.querySelector('button[title*="generateFromPrompt"]');
|
||||
if (actionButton) {
|
||||
// Set the prompt in a temporary storage for the action to pick up
|
||||
(window as any).tempPromptForGeneration = prompt;
|
||||
window.tempPromptForGeneration = prompt;
|
||||
(actionButton as HTMLElement).click();
|
||||
console.log('Triggered generateFromPrompt action with:', prompt);
|
||||
return;
|
||||
@@ -235,8 +236,8 @@ export const useCopilotActions = ({
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||
}, []);
|
||||
|
||||
// Allow external prompts to trigger content generation
|
||||
@@ -248,15 +249,15 @@ export const useCopilotActions = ({
|
||||
],
|
||||
handler: async ({ prompt }: { prompt: string }) => {
|
||||
// Check for temporary prompt from brainstorm flow
|
||||
const finalPrompt = prompt || (window as any).tempPromptForGeneration;
|
||||
const finalPrompt = prompt || window.tempPromptForGeneration;
|
||||
|
||||
if (!finalPrompt) {
|
||||
return { success: false, message: 'No prompt provided' };
|
||||
}
|
||||
|
||||
// Clear the temporary prompt
|
||||
if ((window as any).tempPromptForGeneration) {
|
||||
delete (window as any).tempPromptForGeneration;
|
||||
if (window.tempPromptForGeneration) {
|
||||
delete window.tempPromptForGeneration;
|
||||
}
|
||||
|
||||
// Set the prompt as context and trigger generation
|
||||
@@ -281,9 +282,21 @@ export const useCopilotActions = ({
|
||||
name: 'editLinkedInDraft',
|
||||
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen'] }
|
||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen', 'AddEmojis', 'AddHashtags', 'ImproveClarity', 'AdjustTone', 'RewriteHook'] }
|
||||
],
|
||||
handler: async ({ operation }: { operation: string }) => {
|
||||
const COMING_SOON_OPS = ['ImproveClarity', 'AdjustTone', 'RewriteHook'];
|
||||
if (COMING_SOON_OPS.includes(operation)) {
|
||||
const labels: Record<string, string> = {
|
||||
ImproveClarity: 'Improve Clarity',
|
||||
AdjustTone: 'Tone Adjustment',
|
||||
RewriteHook: 'Hook Rewrite'
|
||||
};
|
||||
const label = labels[operation] || operation;
|
||||
showToastNotification(`${label} is coming soon! This feature will use AI to enhance your content.`, 'info');
|
||||
return { success: false, message: `${label} feature coming soon` };
|
||||
}
|
||||
|
||||
const currentDraft = draft || '';
|
||||
if (!currentDraft) {
|
||||
return { success: false, message: 'No draft content to edit' };
|
||||
@@ -335,6 +348,49 @@ export const useCopilotActions = ({
|
||||
editedContent = currentDraft + '\n\nThis approach has shown remarkable results in our industry. The key is to maintain consistency while adapting to changing market conditions.';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'AddEmojis': {
|
||||
const selectEmojis = (text: string): string[] => {
|
||||
const lower = text.toLowerCase();
|
||||
let category = 'general';
|
||||
if (/achiev|success|milestone|goal|win/i.test(lower)) category = 'achievement';
|
||||
else if (/strateg|plan|growth|metric/i.test(lower)) category = 'strategy';
|
||||
else if (/collabor|team|partner|connect|network/i.test(lower)) category = 'collaboration';
|
||||
else if (/learn|skill|develop|train|educat/i.test(lower)) category = 'learning';
|
||||
else if (/innovat|new|future|transform|ai|tech/i.test(lower)) category = 'innovation';
|
||||
const emojiSets: Record<string, string[]> = {
|
||||
achievement: ['🏆', '🎯', '⭐', '🚀', '💪'],
|
||||
strategy: ['📈', '📊', '🧭', '💡', '🔑'],
|
||||
collaboration: ['🤝', '👥', '💬', '🌐', '🤝'],
|
||||
learning: ['📚', '🎓', '🧠', '💡', '📖'],
|
||||
innovation: ['💡', '🔬', '⚡', '🔮', '✨'],
|
||||
general: ['✅', '🎯', '💡', '📌', '🔥']
|
||||
};
|
||||
return emojiSets[category];
|
||||
};
|
||||
const emojis = selectEmojis(currentDraft);
|
||||
const enriched = currentDraft.split('\n').map((line: string, i: number) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('http')) return line;
|
||||
return `${emojis[i % emojis.length]} ${line}`;
|
||||
}).join('\n');
|
||||
editedContent = enriched;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AddHashtags': {
|
||||
const INDUSTRY_TAGS: Record<string, string[]> = {
|
||||
technology: ['#TechLeadership', '#DigitalTransformation', '#Innovation', '#FutureOfWork', '#AI'],
|
||||
marketing: ['#MarketingStrategy', '#DigitalMarketing', '#ContentMarketing', '#GrowthMarketing', '#BrandBuilding'],
|
||||
default: ['#ProfessionalDevelopment', '#CareerGrowth', '#Leadership', '#IndustryInsights', '#Networking']
|
||||
};
|
||||
const existing: string[] = currentDraft.match(/#[A-Za-z0-9_]+/g) || [];
|
||||
if (existing.length >= 5) break;
|
||||
const tags = (INDUSTRY_TAGS[userPreferences?.industry?.toLowerCase()] || INDUSTRY_TAGS.default)
|
||||
.filter((t: string) => !existing.includes(t)).slice(0, 5);
|
||||
if (tags.length > 0) editedContent = `${currentDraft}\n\n${tags.join(' ')}`;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, message: 'Unknown operation' };
|
||||
@@ -355,34 +411,57 @@ export const useCopilotActions = ({
|
||||
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;
|
||||
const hasPersona = !!(corePersona && platformPersona);
|
||||
|
||||
// Debug logging for suggestions
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
||||
hasContent,
|
||||
justGeneratedContent,
|
||||
hasPersona,
|
||||
draftLength: draft?.length || 0
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
// Initial suggestions for content creation
|
||||
const initialSuggestions = [
|
||||
const initialSuggestions: { title: string; message: string }[] = [];
|
||||
|
||||
// Persona-aware actions first when persona data is available
|
||||
if (hasPersona) {
|
||||
initialSuggestions.push(
|
||||
{ title: '🎭 Post (Persona-Optimized)', message: 'Use tool generateLinkedInPostWithPersona to create a post optimized for your writing style and LinkedIn platform constraints.' },
|
||||
{ title: '🎭 Article (Persona-Optimized)', message: 'Use tool generateLinkedInArticleWithPersona to write an article with persona-aware optimization.' }
|
||||
);
|
||||
}
|
||||
|
||||
// Standard actions
|
||||
initialSuggestions.push(
|
||||
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
||||
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
||||
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
||||
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
||||
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' },
|
||||
{ title: '🎨 Visual Content', message: 'Create engaging visual content with AI-generated images optimized for LinkedIn.' }
|
||||
];
|
||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' }
|
||||
);
|
||||
|
||||
// Persona validation and suggestions when persona is available
|
||||
if (hasPersona) {
|
||||
initialSuggestions.push(
|
||||
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check existing content against your writing persona.' },
|
||||
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations based on your persona.' }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
||||
return initialSuggestions;
|
||||
} else {
|
||||
// Refinement suggestions for existing content - use direct edit actions
|
||||
const refinementSuggestions = [
|
||||
const refinementSuggestions: { title: string; message: string }[] = [
|
||||
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
||||
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
||||
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
||||
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
||||
{ title: '😀 Add emojis', message: 'Use tool editLinkedInDraft with operation AddEmojis' },
|
||||
{ title: '🏷️ Add hashtags', message: 'Use tool editLinkedInDraft with operation AddHashtags' },
|
||||
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
||||
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
||||
];
|
||||
@@ -413,6 +492,14 @@ export const useCopilotActions = ({
|
||||
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
||||
}
|
||||
|
||||
// Persona-aware refinement actions
|
||||
if (hasPersona) {
|
||||
refinementSuggestions.push(
|
||||
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check this content against your writing persona.' },
|
||||
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations.' }
|
||||
);
|
||||
}
|
||||
|
||||
// Add image generation suggestion when there's content
|
||||
if (draft && draft.trim().length > 0) {
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
||||
@@ -439,7 +526,7 @@ export const useCopilotActions = ({
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
||||
return refinementSuggestions;
|
||||
}
|
||||
}, [draft, justGeneratedContent]);
|
||||
}, [draft, justGeneratedContent, corePersona, platformPersona]);
|
||||
|
||||
// Return the suggestions function directly
|
||||
return getIntelligentSuggestions;
|
||||
|
||||
@@ -657,7 +657,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!useGoogleSearch || words.length < 4) return;
|
||||
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
||||
const tone = (corePersona as any)?.tonal_range?.default_tone || (platformPersona as any)?.tonal_range?.default_tone || 'professional';
|
||||
const tone = platformPersona?.tonal_range?.default_tone || 'professional';
|
||||
const goTo = corePersona?.linguistic_fingerprint?.lexical_features?.go_to_words?.slice(0,5)?.join(', ');
|
||||
const platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
||||
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import {
|
||||
AutoAwesome as SparklesIcon,
|
||||
PhotoCamera as PhotoIcon,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as ExclamationTriangleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||
|
||||
interface ImageGenerationSuggestionsProps {
|
||||
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
||||
@@ -51,9 +51,6 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
||||
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
||||
const [showPrompts, setShowPrompts] = useState(false);
|
||||
|
||||
// Use the same pattern as other components in the project
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// Register Copilot action for generating image prompts
|
||||
useCopilotActionTyped({
|
||||
name: 'generate_image_prompts',
|
||||
@@ -119,7 +116,12 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', required: true },
|
||||
{ name: 'content_context', type: 'object', required: true },
|
||||
{ name: 'content_context', type: 'object', required: true, attributes: [
|
||||
{ name: 'topic', type: 'string', required: true },
|
||||
{ name: 'industry', type: 'string', required: true },
|
||||
{ name: 'content_type', type: 'string', required: true },
|
||||
{ name: 'style', type: 'string', required: true }
|
||||
] },
|
||||
{ name: 'aspect_ratio', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
||||
|
||||
interface ProgressStep {
|
||||
export interface ProgressStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ProgressStatus;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||
|
||||
interface QuickCreateProps {
|
||||
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
userPreferences: LinkedInPreferences;
|
||||
}
|
||||
|
||||
type ContentType = 'post' | 'article' | 'carousel' | 'video_script';
|
||||
|
||||
const CONTENT_TYPES: { type: ContentType; label: string; icon: string; description: string; color: string }[] = [
|
||||
{ type: 'post', label: 'Post', icon: '📝', description: 'Professional LinkedIn post with engagement hooks', color: '#0a66c2' },
|
||||
{ type: 'article', label: 'Article', icon: '📄', description: 'Thought leadership article with in-depth analysis', color: '#057642' },
|
||||
{ type: 'carousel', label: 'Carousel', icon: '🎠', description: 'Multi-slide carousel for visual storytelling', color: '#8b5cf6' },
|
||||
{ type: 'video_script', label: 'Video Script', icon: '🎬', description: 'Engaging video script with hook & scenes', color: '#dc2626' }
|
||||
];
|
||||
|
||||
const TONES = ['Professional', 'Conversational', 'Authoritative', 'Inspirational', 'Educational', 'Friendly'];
|
||||
const INDUSTRIES = ['Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing', 'Retail', 'Marketing', 'Consulting', 'Real Estate', 'Legal', 'Non-profit', 'Entertainment', 'Energy', 'Custom'];
|
||||
|
||||
const defaultForm = {
|
||||
topic: '',
|
||||
industry: '',
|
||||
tone: '',
|
||||
target_audience: '',
|
||||
key_points: '',
|
||||
post_type: '',
|
||||
word_count: 1500,
|
||||
number_of_slides: 8,
|
||||
video_length: 60,
|
||||
key_takeaways: '',
|
||||
key_messages: '',
|
||||
key_sections: ''
|
||||
};
|
||||
|
||||
export const QuickCreate: React.FC<QuickCreateProps> = ({
|
||||
onGeneratePost,
|
||||
onGenerateArticle,
|
||||
onGenerateCarousel,
|
||||
onGenerateVideoScript,
|
||||
userPreferences
|
||||
}) => {
|
||||
const [selectedType, setSelectedType] = useState<ContentType | null>(null);
|
||||
const [formData, setFormData] = useState(defaultForm);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const openModal = (type: ContentType) => {
|
||||
setFormData({
|
||||
...defaultForm,
|
||||
industry: userPreferences?.industry || '',
|
||||
tone: userPreferences?.tone || 'Professional',
|
||||
target_audience: userPreferences?.target_audience || ''
|
||||
});
|
||||
setSelectedType(type);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setSelectedType(null);
|
||||
setFormData(defaultForm);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedType || generating) return;
|
||||
setGenerating(true);
|
||||
const params = { ...formData };
|
||||
try {
|
||||
const generators = {
|
||||
post: onGeneratePost,
|
||||
article: onGenerateArticle,
|
||||
carousel: onGenerateCarousel,
|
||||
video_script: onGenerateVideoScript
|
||||
};
|
||||
await generators[selectedType](params);
|
||||
closeModal();
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setField = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const modalContent = useMemo(() => {
|
||||
if (!selectedType) return null;
|
||||
|
||||
const commonFields = (
|
||||
<>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Topic *</label>
|
||||
<input
|
||||
value={formData.topic}
|
||||
onChange={e => setField('topic', e.target.value)}
|
||||
placeholder={`e.g., ${selectedType === 'video_script' ? 'Networking tips' : 'AI trends in ' + (formData.industry || 'Technology')}`}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Industry</label>
|
||||
<select
|
||||
value={formData.industry}
|
||||
onChange={e => setField('industry', e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||
>
|
||||
{INDUSTRIES.map(ind => <option key={ind} value={ind}>{ind}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Tone</label>
|
||||
<select
|
||||
value={formData.tone}
|
||||
onChange={e => setField('tone', e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||
>
|
||||
{TONES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Target Audience</label>
|
||||
<input
|
||||
value={formData.target_audience}
|
||||
onChange={e => setField('target_audience', e.target.value)}
|
||||
placeholder="e.g., Product Managers, CTOs"
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (selectedType) {
|
||||
case 'post':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Post</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Points</label>
|
||||
<textarea
|
||||
value={formData.key_points}
|
||||
onChange={e => setField('key_points', e.target.value)}
|
||||
placeholder="Key point 1 / Key point 2 / Key point 3"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'article':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Article</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Word Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.word_count}
|
||||
onChange={e => setField('word_count', parseInt(e.target.value) || 1500)}
|
||||
min={500}
|
||||
max={5000}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Sections</label>
|
||||
<textarea
|
||||
value={formData.key_sections}
|
||||
onChange={e => setField('key_sections', e.target.value)}
|
||||
placeholder="Introduction / Current challenges / Best practices / Future outlook"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'carousel':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Carousel</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Number of Slides</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.number_of_slides}
|
||||
onChange={e => setField('number_of_slides', parseInt(e.target.value) || 8)}
|
||||
min={3}
|
||||
max={20}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Takeaways</label>
|
||||
<textarea
|
||||
value={formData.key_takeaways}
|
||||
onChange={e => setField('key_takeaways', e.target.value)}
|
||||
placeholder="Key insight / Important trend / Actionable tip"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'video_script':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Video Script</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Video Length (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.video_length}
|
||||
onChange={e => setField('video_length', parseInt(e.target.value) || 60)}
|
||||
min={15}
|
||||
max={600}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Messages</label>
|
||||
<textarea
|
||||
value={formData.key_messages}
|
||||
onChange={e => setField('key_messages', e.target.value)}
|
||||
placeholder="Core message / Practical advice / Call to action"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [selectedType, formData]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', marginTop: 8 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 12, textAlign: 'center' }}>Quick Create</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
|
||||
{CONTENT_TYPES.map(({ type, label, icon, description, color }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => openModal(type)}
|
||||
style={{
|
||||
padding: '14px 10px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 12,
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)'
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${color}20`;
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.06)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, marginBottom: 6 }}>{icon}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#111827' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 4, lineHeight: '1.3' }}>{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generation Modal */}
|
||||
{selectedType && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10020, padding: 20 }}>
|
||||
<div style={{ background: 'white', width: 520, maxWidth: '100%', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #e5e7eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 15, color: '#111827' }}>{CONTENT_TYPES.find(c => c.type === selectedType)?.icon} {CONTENT_TYPES.find(c => c.type === selectedType)?.label}</div>
|
||||
<button onClick={closeModal} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#6b7280', padding: '4px 8px', borderRadius: 6 }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: 16, maxHeight: '60vh', overflow: 'auto' }}>
|
||||
{modalContent}
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeModal} style={{ padding: '10px 20px', border: '1px solid #d1d5db', borderRadius: 8, background: 'white', cursor: 'pointer', fontSize: 14, fontWeight: 600 }}>Cancel</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !formData.topic.trim()}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
background: generating ? '#9ca3af' : CONTENT_TYPES.find(c => c.type === selectedType)?.color || '#0a66c2',
|
||||
color: 'white',
|
||||
cursor: generating || !formData.topic.trim() ? 'not-allowed' : 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
opacity: generating || !formData.topic.trim() ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{generating && <div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid white', borderTopColor: 'transparent', animation: 'spin 0.8s linear infinite' }} />}
|
||||
{generating ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FeatureCarousel } from './FeatureCarousel';
|
||||
import { InfoModals } from './InfoModals';
|
||||
import { QuickCreate } from './QuickCreate';
|
||||
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||
|
||||
interface WelcomeMessageProps {
|
||||
draft: string;
|
||||
isGenerating: boolean;
|
||||
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
userPreferences: LinkedInPreferences;
|
||||
}
|
||||
|
||||
export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||
draft,
|
||||
isGenerating
|
||||
isGenerating,
|
||||
onGeneratePost,
|
||||
onGenerateArticle,
|
||||
onGenerateCarousel,
|
||||
onGenerateVideoScript,
|
||||
userPreferences
|
||||
}) => {
|
||||
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
||||
const [showAssistiveModal, setShowAssistiveModal] = useState(false);
|
||||
@@ -267,6 +279,17 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||
Choose your preferred AI assistance mode to get started with content creation.
|
||||
</p>
|
||||
|
||||
{/* Quick Create - Direct generation buttons */}
|
||||
<div style={{ width: '100%', maxWidth: 640, marginBottom: 24 }}>
|
||||
<QuickCreate
|
||||
onGeneratePost={onGeneratePost}
|
||||
onGenerateArticle={onGenerateArticle}
|
||||
onGenerateCarousel={onGenerateCarousel}
|
||||
onGenerateVideoScript={onGenerateVideoScript}
|
||||
userPreferences={userPreferences}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Modals */}
|
||||
<InfoModals
|
||||
showCopilotModal={showCopilotModal}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export { default as PostHITL } from './PostHITL';
|
||||
export { default as ArticleHITL } from './ArticleHITL';
|
||||
export { default as CarouselHITL } from './CarouselHITL';
|
||||
export { default as VideoScriptHITL } from './VideoScriptHITL';
|
||||
export { default as CommentResponseHITL } from './CommentResponseHITL';
|
||||
|
||||
// New refactored components
|
||||
export { Header } from './Header';
|
||||
export { ContentEditor } from './ContentEditor';
|
||||
@@ -12,6 +6,7 @@ export { WelcomeMessage } from './WelcomeMessage';
|
||||
export { FeatureCarousel } from './FeatureCarousel';
|
||||
export { InfoModals } from './InfoModals';
|
||||
export { ProgressTracker } from './ProgressTracker';
|
||||
export type { ProgressStep } from './ProgressTracker';
|
||||
export { ContentRecommendations } from './ContentRecommendations';
|
||||
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
||||
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
||||
@@ -27,3 +22,4 @@ export { default as ImageGenerationTest } from './ImageGenerationTest';
|
||||
// Refactored Components
|
||||
export { default as BrainstormFlow } from './BrainstormFlow';
|
||||
export { useCopilotActions } from './CopilotActions';
|
||||
export { QuickCreate } from './QuickCreate';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useCopilotReadable } from '@copilotkit/react-core';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
loadHistory,
|
||||
clearHistory,
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
type ChatMsg,
|
||||
type LinkedInPreferences
|
||||
} from '../utils/storageUtils';
|
||||
import { getContextAwareSuggestions } from '../utils/linkedInWriterUtils';
|
||||
import { getContextAwareSuggestions, mapPostType, mapTone, mapIndustry, mapSearchEngine, readPrefs } from '../utils/linkedInWriterUtils';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../../services/linkedInWriterApi';
|
||||
|
||||
export function useLinkedInWriter() {
|
||||
// Core state
|
||||
@@ -51,24 +51,18 @@ export function useLinkedInWriter() {
|
||||
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
||||
|
||||
// UI state
|
||||
const [currentSuggestions, setCurrentSuggestions] = useState<Array<{title: string, message: string, priority?: string}>>([]);
|
||||
const currentSuggestions = useMemo(() => getContextAwareSuggestions(
|
||||
userPreferences,
|
||||
draft,
|
||||
chatHistory.slice(-5),
|
||||
userPreferences.last_used_actions || []
|
||||
), [userPreferences, draft, chatHistory]);
|
||||
const [showContextPanel, setShowContextPanel] = useState(false);
|
||||
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
||||
const [showContextModal, setShowContextModal] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [justGeneratedContent, setJustGeneratedContent] = useState(false);
|
||||
|
||||
// Update suggestions when context changes
|
||||
const updateSuggestions = useCallback(() => {
|
||||
const newSuggestions = getContextAwareSuggestions(
|
||||
userPreferences,
|
||||
draft,
|
||||
chatHistory.slice(-5),
|
||||
userPreferences.last_used_actions || []
|
||||
);
|
||||
setCurrentSuggestions(newSuggestions);
|
||||
}, [userPreferences, draft, chatHistory]);
|
||||
|
||||
// Track action usage and update preferences
|
||||
const trackActionUsage = useCallback((actionName: string) => {
|
||||
const currentPrefs = getPreferences();
|
||||
@@ -82,10 +76,278 @@ export function useLinkedInWriter() {
|
||||
// Reset the flag after 30 seconds
|
||||
setTimeout(() => setJustGeneratedContent(false), 30000);
|
||||
}
|
||||
|
||||
// Update suggestions after action usage
|
||||
setTimeout(() => updateSuggestions(), 100);
|
||||
}, [updateSuggestions]);
|
||||
}, []);
|
||||
|
||||
// ── Direct generation methods (UI-driven, no CopilotKit dependency) ──────────
|
||||
const generatePost = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInPost', message: 'Generating LinkedIn post...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating content' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generatePost({
|
||||
topic: params?.topic || prefs.topic || 'AI transformation in business',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
post_type: mapPostType(params?.post_type || prefs.post_type),
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Business leaders and professionals',
|
||||
key_points: params?.key_points || prefs.key_points || [],
|
||||
include_hashtags: params?.include_hashtags ?? (prefs.include_hashtags ?? true),
|
||||
include_call_to_action: params?.include_call_to_action ?? (prefs.include_call_to_action ?? true),
|
||||
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||
max_length: params?.max_length || prefs.max_length || 2000,
|
||||
grounding_level: 'enhanced' as GroundingLevel,
|
||||
include_citations: true
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Content generated' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
const content = res.data.content;
|
||||
const hashtags = res.data.hashtags?.map((h: any) => h.hashtag).join(' ') || '';
|
||||
const cta = res.data.call_to_action || '';
|
||||
let fullContent = content;
|
||||
if (hashtags) fullContent += `\n\n${hashtags}`;
|
||||
if (cta) fullContent += `\n\n${cta}`;
|
||||
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 || []
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: fullContent }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Content finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInPost');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateArticle = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInArticle', message: 'Generating LinkedIn article...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating article content' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateArticle({
|
||||
topic: params?.topic || prefs.topic || 'Digital transformation strategies',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Industry professionals and executives',
|
||||
key_sections: params?.key_sections || prefs.key_sections || [],
|
||||
include_images: params?.include_images ?? (prefs.include_images ?? true),
|
||||
seo_optimization: params?.seo_optimization ?? (prefs.seo_optimization ?? true),
|
||||
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||
word_count: params?.word_count || prefs.word_count || 1500,
|
||||
grounding_level: 'enhanced' as GroundingLevel,
|
||||
include_citations: true
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Article content generated' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
const content = `# ${res.data.title}\n\n${res.data.content}`;
|
||||
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 || []
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Article finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInArticle');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateCarousel = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInCarousel', message: 'Generating LinkedIn carousel...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating carousel slides' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateCarousel({
|
||||
topic: params?.topic || prefs.topic || 'Professional development tips',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
number_of_slides: params?.number_of_slides || prefs.number_of_slides || 8,
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||
key_takeaways: params?.key_takeaways || prefs.key_takeaways || [],
|
||||
include_cover_slide: params?.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||
include_cta_slide: params?.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||
visual_style: params?.visual_style || prefs.visual_style || 'modern'
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated ${res.data.slides?.length || 0} slides` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
let content = `# ${res.data.title}\n\n`;
|
||||
res.data.slides.forEach((slide: any, index: number) => {
|
||||
content += `## Slide ${index + 1}: ${slide.title}\n\n${slide.content}\n\n`;
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Carousel finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInCarousel');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateVideoScript = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInVideoScript', message: 'Generating LinkedIn video script...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating video script' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateVideoScript({
|
||||
topic: params?.topic || prefs.topic || 'Professional networking tips',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
video_length: params?.video_length || prefs.video_length || 60,
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Professional networkers',
|
||||
key_messages: params?.key_messages || prefs.key_messages || [],
|
||||
include_hook: params?.include_hook ?? (prefs.include_hook ?? true),
|
||||
include_captions: params?.include_captions ?? (prefs.include_captions ?? true)
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated script with ${res.data.main_content?.length || 0} scenes` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
let content = `# Video Script: ${params?.topic || 'Professional Content'}\n\n`;
|
||||
content += `## Hook\n${res.data.hook}\n\n`;
|
||||
content += `## Main Content\n`;
|
||||
res.data.main_content.forEach((scene: any, index: number) => {
|
||||
content += `### Scene ${index + 1} (${scene.duration || '30s'})\n${scene.content}\n\n`;
|
||||
});
|
||||
content += `## Conclusion\n${res.data.conclusion}\n\n`;
|
||||
content += `## Video Description\n${res.data.video_description}\n\n`;
|
||||
if (res.data.captions) {
|
||||
content += `## Captions\n${res.data.captions.join('\n')}\n\n`;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Video script finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInVideoScript');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize chat history and preferences from localStorage
|
||||
useEffect(() => {
|
||||
@@ -229,11 +491,6 @@ export function useLinkedInWriter() {
|
||||
}
|
||||
}, [context]);
|
||||
|
||||
// Update suggestions when relevant state changes
|
||||
useEffect(() => {
|
||||
updateSuggestions();
|
||||
}, [updateSuggestions]);
|
||||
|
||||
// Handle draft updates from CopilotKit actions
|
||||
useEffect(() => {
|
||||
const handleUpdateDraft = (event: CustomEvent) => {
|
||||
@@ -246,9 +503,7 @@ export function useLinkedInWriter() {
|
||||
setCurrentAction(null);
|
||||
// Auto-show preview when new content is generated
|
||||
setShowPreview(true);
|
||||
// Hide progress tracker when content is generated
|
||||
setProgressActive(false);
|
||||
setProgressSteps([]);
|
||||
// Progress is finalized by the progressStep/progressComplete events dispatched after this
|
||||
console.log('[LinkedIn Writer] Draft update complete');
|
||||
};
|
||||
|
||||
@@ -340,22 +595,6 @@ export function useLinkedInWriter() {
|
||||
console.log('[LinkedIn Writer] Chat memory cleared by user');
|
||||
}, []);
|
||||
|
||||
// Make content available to CopilotKit
|
||||
useCopilotReadable({
|
||||
description: 'Current LinkedIn content draft',
|
||||
value: draft
|
||||
});
|
||||
|
||||
useCopilotReadable({
|
||||
description: 'Context and notes for LinkedIn content',
|
||||
value: context
|
||||
});
|
||||
|
||||
useCopilotReadable({
|
||||
description: 'User preferences for LinkedIn content (tone, industry, audience, style, options)',
|
||||
value: userPreferences
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
draft,
|
||||
@@ -403,11 +642,16 @@ export function useLinkedInWriter() {
|
||||
|
||||
// Utilities
|
||||
trackActionUsage,
|
||||
updateSuggestions,
|
||||
getHistoryLength,
|
||||
savePreferences,
|
||||
summarizeHistory,
|
||||
|
||||
// Direct generation methods
|
||||
generatePost,
|
||||
generateArticle,
|
||||
generateCarousel,
|
||||
generateVideoScript,
|
||||
|
||||
// Grounding data
|
||||
researchSources,
|
||||
citations,
|
||||
|
||||
@@ -24,7 +24,8 @@ export const VALID_TONES = [
|
||||
|
||||
export const VALID_SEARCH_ENGINES = [
|
||||
'google',
|
||||
'tavily'
|
||||
'tavily',
|
||||
'exa'
|
||||
] as const;
|
||||
|
||||
export const VALID_INDUSTRIES = [
|
||||
@@ -157,21 +158,17 @@ export function mapIndustry(industry: string | undefined): string {
|
||||
}
|
||||
|
||||
export function mapSearchEngine(engine: string | undefined): SearchEngine {
|
||||
// Force Google for now until METAPHOR issue is resolved
|
||||
return SearchEngine.GOOGLE;
|
||||
|
||||
/* Original logic - commented out temporarily
|
||||
const eng = normalizeEnum(engine);
|
||||
if (!eng) return SearchEngine.GOOGLE;
|
||||
if (!eng) return SearchEngine.EXA;
|
||||
|
||||
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
||||
if (exact) return exact as SearchEngine;
|
||||
|
||||
if (eng.includes('exa')) return SearchEngine.EXA;
|
||||
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
||||
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
||||
|
||||
return SearchEngine.GOOGLE;
|
||||
*/
|
||||
return SearchEngine.EXA;
|
||||
}
|
||||
|
||||
export function mapResponseType(responseType: string | undefined): string {
|
||||
|
||||
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
interface ParameterDescriptor {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
attributes?: ParameterDescriptor[];
|
||||
}
|
||||
|
||||
interface CopilotActionConfig<TArgs = Record<string, any>> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: ParameterDescriptor[];
|
||||
handler: (args: TArgs) => Promise<any>;
|
||||
}
|
||||
|
||||
export function useCopilotActionTyped<TArgs = Record<string, any>>(
|
||||
config: CopilotActionConfig<TArgs>
|
||||
): void {
|
||||
(useCopilotAction as (config: unknown) => void)(config);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export enum LinkedInTone {
|
||||
|
||||
export enum SearchEngine {
|
||||
GOOGLE = 'google',
|
||||
TAVILY = 'tavily'
|
||||
TAVILY = 'tavily',
|
||||
EXA = 'exa'
|
||||
}
|
||||
|
||||
export enum GroundingLevel {
|
||||
@@ -66,7 +67,7 @@ export interface LinkedInArticleRequest {
|
||||
export interface LinkedInCarouselRequest {
|
||||
topic: string;
|
||||
industry: string;
|
||||
slide_count?: number;
|
||||
number_of_slides?: number;
|
||||
tone?: LinkedInTone;
|
||||
target_audience?: string;
|
||||
key_takeaways?: string[];
|
||||
@@ -238,6 +239,24 @@ export interface LinkedInCommentResponseResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LinkedInEditContentRequest {
|
||||
content: string;
|
||||
edit_type: 'professionalize' | 'optimize_engagement' | 'add_hashtags' | 'adjust_tone' | 'expand' | 'condense' | 'add_cta';
|
||||
industry?: string;
|
||||
tone?: string;
|
||||
target_audience?: string;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface LinkedInEditContentResponse {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
edit_type: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// API client
|
||||
export const linkedInWriterApi = {
|
||||
async health(): Promise<any> {
|
||||
@@ -270,18 +289,64 @@ export const linkedInWriterApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async optimizeProfile(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/optimize-profile', request);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generatePoll(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/generate-poll', request);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateCompanyUpdate(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/generate-company-update', request);
|
||||
async editContent(request: LinkedInEditContentRequest): Promise<LinkedInEditContentResponse> {
|
||||
const { data } = await aiApiClient.post('/api/linkedin/edit-content', request);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Asset Library Save ────────────────────────────────────────────────
|
||||
|
||||
export interface SaveLinkedInAssetParams {
|
||||
title: string;
|
||||
content: string;
|
||||
topic?: string;
|
||||
tags?: string[];
|
||||
assetMetadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SaveLinkedInAssetResult {
|
||||
assetId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a LinkedIn post to the Asset Library.
|
||||
* Uses the generic Content Asset API (POST /api/content-assets/).
|
||||
*/
|
||||
export const saveLinkedInToAssetLibrary = async (
|
||||
params: SaveLinkedInAssetParams
|
||||
): Promise<SaveLinkedInAssetResult> => {
|
||||
// Build a filename from the title
|
||||
const safeTitle = (params.title || 'linkedin-post')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 80);
|
||||
const filename = `${safeTitle}-${Date.now()}.txt`;
|
||||
|
||||
const tags = [
|
||||
'linkedin',
|
||||
'social',
|
||||
'ai_generated',
|
||||
...(params.tags || []),
|
||||
];
|
||||
|
||||
const response = await aiApiClient.post('/api/content-assets/', {
|
||||
asset_type: 'text',
|
||||
source_module: 'linkedin_writer',
|
||||
filename,
|
||||
file_url: `linkedin://posts/${filename}`,
|
||||
title: params.title,
|
||||
description: params.content,
|
||||
prompt: params.topic || '',
|
||||
tags,
|
||||
asset_metadata: {
|
||||
platform: 'linkedin',
|
||||
content_type: 'linkedin_post',
|
||||
word_count: params.content ? params.content.split(/\s+/).length : 0,
|
||||
...(params.assetMetadata || {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { assetId: response.data.id };
|
||||
};
|
||||
|
||||
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface LinkedInWriterGlobals {
|
||||
tempPromptForGeneration?: string;
|
||||
lastBrainstormEvent?: CustomEvent;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'linkedinwriter:copilotSeedFromPrompt': CustomEvent<{ prompt: string }>;
|
||||
'linkedinwriter:runGoogleSearchForIdeas': CustomEvent<{
|
||||
prompt?: string;
|
||||
seed?: string;
|
||||
forceRefresh?: boolean;
|
||||
usePersona?: boolean;
|
||||
useGoogleSearch?: boolean;
|
||||
includeTrending?: boolean;
|
||||
remarketContent?: boolean;
|
||||
}>;
|
||||
'linkedinwriter:updateDraft': CustomEvent<string>;
|
||||
'linkedinwriter:applyEdit': CustomEvent<{ target: string }>;
|
||||
'linkedinwriter:loadingStart': CustomEvent<{ action: string; message: string }>;
|
||||
'linkedinwriter:loadingEnd': CustomEvent<{ error?: string }>;
|
||||
'linkedinwriter:progressInit': CustomEvent<{ steps: Array<{ id: string; label: string }> }>;
|
||||
'linkedinwriter:progressStep': CustomEvent<{
|
||||
id: string;
|
||||
status: 'active' | 'completed' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
'linkedinwriter:progressComplete': CustomEvent;
|
||||
'linkedinwriter:progressError': CustomEvent<{ id: string; details: string }>;
|
||||
'linkedinwriter:updateGroundingData': CustomEvent<{
|
||||
researchSources: any[];
|
||||
citations: any[];
|
||||
qualityMetrics: any;
|
||||
groundingEnabled: boolean;
|
||||
searchQueries: string[];
|
||||
}>;
|
||||
'linkedinwriter:showTodaysTasks': CustomEvent;
|
||||
'linkedinwriter:updateLinkedInPreferences': CustomEvent;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
tempPromptForGeneration?: string;
|
||||
lastBrainstormEvent?: CustomEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user