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:
ajaysi
2026-06-12 18:58:53 +05:30
parent e54aaa7a3e
commit 63a0df2536
37 changed files with 2891 additions and 1355 deletions

View File

@@ -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>
);

View File

@@ -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' };
}
});

View File

@@ -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 || [],

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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>

View File

@@ -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;

View File

@@ -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.' : '';

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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 {

View 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);
}

View File

@@ -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 };
};

View 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 {};