diff --git a/backend/api/brainstorm.py b/backend/api/brainstorm.py new file mode 100644 index 00000000..24e195df --- /dev/null +++ b/backend/api/brainstorm.py @@ -0,0 +1,295 @@ +""" +Brainstorming endpoints for generating Google search prompts and running a +single grounded search to surface topic ideas. Built for reusability across +editors. Uses the existing Gemini provider modules. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from loguru import logger + +from services.llm_providers.gemini_provider import gemini_structured_json_response + +try: + from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider + GROUNDED_AVAILABLE = True +except Exception: + GROUNDED_AVAILABLE = False + + +router = APIRouter(prefix="/api/brainstorm", tags=["Brainstorming"]) + + +class PersonaPayload(BaseModel): + persona_name: Optional[str] = None + archetype: Optional[str] = None + core_belief: Optional[str] = None + tonal_range: Optional[Dict[str, Any]] = None + linguistic_fingerprint: Optional[Dict[str, Any]] = None + + +class PlatformPersonaPayload(BaseModel): + content_format_rules: Optional[Dict[str, Any]] = None + engagement_patterns: Optional[Dict[str, Any]] = None + content_types: Optional[Dict[str, Any]] = None + tonal_range: Optional[Dict[str, Any]] = None + + +class PromptRequest(BaseModel): + seed: str = Field(..., description="Idea seed provided by end user") + persona: Optional[PersonaPayload] = None + platformPersona: Optional[PlatformPersonaPayload] = None + count: int = Field(5, ge=3, le=10, description="Number of prompts to generate (default 5)") + + +class PromptResponse(BaseModel): + prompts: List[str] + + +@router.post("/prompts", response_model=PromptResponse) +async def generate_prompts(req: PromptRequest) -> PromptResponse: + """Generate N high-signal Google search prompts using Gemini structured output.""" + try: + persona_line = "" + if req.persona: + parts = [] + if req.persona.persona_name: + parts.append(req.persona.persona_name) + if req.persona.archetype: + parts.append(f"({req.persona.archetype})") + persona_line = " ".join(parts) + + platform_hints = [] + if req.platformPersona and req.platformPersona.content_format_rules: + limit = req.platformPersona.content_format_rules.get("character_limit") + if limit: + platform_hints.append(f"respect LinkedIn character limit {limit}") + + sys_prompt = ( + "You are an expert LinkedIn strategist who crafts precise Google search prompts " + "to ideate content topics. Follow Google grounding best-practices: be specific, " + "time-bound (2024-2025), include entities, and prefer intent-rich phrasing." + ) + + prompt = f""" +Seed: {req.seed} +Persona: {persona_line or 'N/A'} +Guidelines: +- Generate {req.count} distinct, high-signal Google search prompts. +- Each prompt should include concrete entities (companies, tools, frameworks) when possible. +- Prefer phrasing that yields recent, authoritative sources. +- Avoid generic phrasing ("latest trends") unless combined with concrete qualifiers. +- Optimize for LinkedIn thought leadership and practicality. +{('Platform hints: ' + ', '.join(platform_hints)) if platform_hints else ''} + +Return only the list of prompts. +""".strip() + + schema = { + "type": "object", + "properties": { + "prompts": { + "type": "array", + "items": {"type": "string"} + } + } + } + + result = gemini_structured_json_response( + prompt=prompt, + schema=schema, + temperature=0.2, + top_p=0.9, + top_k=40, + max_tokens=2048, + system_prompt=sys_prompt, + ) + + prompts = [] + if isinstance(result, dict) and isinstance(result.get("prompts"), list): + prompts = [str(p).strip() for p in result["prompts"] if str(p).strip()] + + if not prompts: + # Minimal fallback: derive simple variations + base = req.seed.strip() + prompts = [ + f"Recent data-backed insights about {base}", + f"Case studies and benchmarks on {base}", + f"Implementation playbooks for {base}", + f"Common pitfalls and solutions in {base}", + f"Industry leader perspectives on {base}", + ] + + return PromptResponse(prompts=prompts[: req.count]) + except Exception as e: + logger.error(f"Error generating brainstorm prompts: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SearchRequest(BaseModel): + prompt: str = Field(..., description="Selected search prompt to run with grounding") + max_tokens: int = Field(1024, ge=256, le=4096) + + +class SearchResult(BaseModel): + title: Optional[str] = None + url: Optional[str] = None + snippet: Optional[str] = None + + +class SearchResponse(BaseModel): + results: List[SearchResult] = [] + + +@router.post("/search", response_model=SearchResponse) +async def run_grounded_search(req: SearchRequest) -> SearchResponse: + """Run a single grounded Google search via GeminiGroundedProvider and return normalized results.""" + if not GROUNDED_AVAILABLE: + raise HTTPException(status_code=503, detail="Grounded provider not available") + + try: + provider = GeminiGroundedProvider() + resp = await provider.generate_grounded_content( + prompt=req.prompt, + content_type="linkedin_post", + temperature=0.3, + max_tokens=req.max_tokens, + ) + + items: List[SearchResult] = [] + # Normalize 'sources' if present + for s in (resp.get("sources") or []): + items.append(SearchResult( + title=s.get("title") or "Source", + url=s.get("url") or s.get("link"), + snippet=s.get("content") or s.get("snippet") + )) + + # Provide minimal fallback if no structured sources are returned + if not items and resp.get("content"): + items.append(SearchResult(title="Generated overview", url=None, snippet=resp.get("content")[:400])) + + return SearchResponse(results=items[:10]) + except Exception as e: + logger.error(f"Error in grounded search: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class IdeasRequest(BaseModel): + seed: str + persona: Optional[PersonaPayload] = None + platformPersona: Optional[PlatformPersonaPayload] = None + results: List[SearchResult] = [] + count: int = 5 + + +class IdeaItem(BaseModel): + prompt: str + rationale: Optional[str] = None + + +class IdeasResponse(BaseModel): + ideas: List[IdeaItem] + + +@router.post("/ideas", response_model=IdeasResponse) +async def generate_brainstorm_ideas(req: IdeasRequest) -> IdeasResponse: + """ + Create brainstorm ideas by combining persona, seed, and Google search results. + Uses gemini_structured_json_response for consistent output. + """ + try: + # Build compact search context + top_results = req.results[:5] + sources_block = "\n".join( + [ + f"- {r.title or 'Source'} | {r.url or ''} | {r.snippet or ''}" + for r in top_results + ] + ) or "(no sources)" + + persona_block = "" + if req.persona: + persona_block = ( + f"Persona: {req.persona.persona_name or ''} {('(' + req.persona.archetype + ')') if req.persona.archetype else ''}\n" + ) + + platform_block = "" + if req.platformPersona and req.platformPersona.content_format_rules: + limit = req.platformPersona.content_format_rules.get("character_limit") + platform_block = f"LinkedIn character limit: {limit}" if limit else "" + + sys_prompt = ( + "You are an enterprise-grade LinkedIn strategist. Generate specific, non-generic " + "brainstorm prompts suitable for LinkedIn posts or carousels. Use the provided web " + "sources to ground ideas and the persona to align tone and style." + ) + + prompt = f""" +SEED IDEA: {req.seed} +{persona_block} +{platform_block} + +RECENT WEB SOURCES (top {len(top_results)}): +{sources_block} + +TASK: +- Propose {req.count} LinkedIn-ready brainstorm prompts tailored to the persona and grounded in the sources. +- Each prompt should be specific and actionable for 2024–2025. +- Prefer thought-leadership angles, contrarian takes with evidence, or practical playbooks. +- Avoid generic phrases like "latest trends" unless qualified by entities. + +Return JSON with an array named ideas where each item has: +- prompt: the exact text the user can use to generate a post +- rationale: 1–2 sentence why this works for the audience/persona +""".strip() + + schema = { + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "rationale": {"type": "string"}, + }, + }, + } + }, + } + + result = gemini_structured_json_response( + prompt=prompt, + schema=schema, + temperature=0.2, + top_p=0.9, + top_k=40, + max_tokens=2048, + system_prompt=sys_prompt, + ) + + ideas: List[IdeaItem] = [] + if isinstance(result, dict) and isinstance(result.get("ideas"), list): + for item in result["ideas"]: + if isinstance(item, dict) and item.get("prompt"): + ideas.append(IdeaItem(prompt=item["prompt"], rationale=item.get("rationale"))) + + if not ideas: + # Fallback basic ideas from seed if model returns nothing + ideas = [ + IdeaItem(prompt=f"Explain why {req.seed} matters now with 2 recent stats", rationale="Timely and data-backed."), + IdeaItem(prompt=f"Common pitfalls in {req.seed} and how to avoid them", rationale="Actionable and experience-based."), + IdeaItem(prompt=f"A step-by-step playbook to implement {req.seed}", rationale="Practical value."), + IdeaItem(prompt=f"Case study: measurable impact of {req.seed}", rationale="Story + ROI."), + IdeaItem(prompt=f"Contrarian take: what most get wrong about {req.seed}", rationale="Thought leadership.") + ] + + return IdeasResponse(ideas=ideas[: req.count]) + except Exception as e: + logger.error(f"Error generating brainstorm ideas: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + diff --git a/backend/api/persona.py b/backend/api/persona.py index 78ea11b6..fb86ce66 100644 --- a/backend/api/persona.py +++ b/backend/api/persona.py @@ -262,6 +262,75 @@ async def delete_persona(user_id: int, persona_id: int): logger.error(f"Error deleting persona: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to delete persona: {str(e)}") +async def update_platform_persona(user_id: int, platform: str, update_data: Dict[str, Any]): + """Update platform-specific persona fields for a user's persona. + + This updates the underlying PlatformPersona row for the given platform. + """ + try: + from services.database import get_db_session + from models.persona_models import WritingPersona, PlatformPersona + + session = get_db_session() + + # Find the user's active core persona id + core_persona = session.query(WritingPersona).filter( + WritingPersona.user_id == user_id, + WritingPersona.is_active == True + ).order_by(WritingPersona.created_at.desc()).first() + + if not core_persona: + raise HTTPException(status_code=404, detail="No active persona found for user") + + # Find the platform persona for the requested platform + platform_persona = session.query(PlatformPersona).filter( + PlatformPersona.writing_persona_id == core_persona.id, + PlatformPersona.platform_type.ilike(platform), + PlatformPersona.is_active == True + ).first() + + if not platform_persona: + raise HTTPException(status_code=404, detail=f"No platform persona found for platform {platform}") + + # Update allowed platform fields + updatable_fields = [ + 'sentence_metrics', 'lexical_features', 'rhetorical_devices', 'tonal_range', + 'stylistic_constraints', 'content_format_rules', 'engagement_patterns', + 'posting_frequency', 'content_types', 'platform_best_practices', 'algorithm_considerations' + ] + + updated_any = False + for field in updatable_fields: + if field in update_data: + setattr(platform_persona, field, update_data[field]) + updated_any = True + + if not updated_any: + # Nothing to update + session.close() + return { + "message": "No updatable fields provided", + "platform": platform_persona.platform_type, + "persona_id": core_persona.id + } + + platform_persona.updated_at = datetime.utcnow() + session.commit() + session.close() + + return { + "message": "Platform persona updated successfully", + "platform": platform, + "persona_id": core_persona.id, + "updated_at": platform_persona.updated_at.isoformat() + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating platform persona: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to update platform persona: {str(e)}") + async def validate_persona_generation_readiness(user_id: int): """Check if user has sufficient onboarding data for persona generation.""" try: diff --git a/backend/api/persona_routes.py b/backend/api/persona_routes.py index 460501e0..38b7f7f6 100644 --- a/backend/api/persona_routes.py +++ b/backend/api/persona_routes.py @@ -32,6 +32,7 @@ from api.persona import ( ) from services.persona_replication_engine import PersonaReplicationEngine +from api.persona import update_platform_persona # Create router router = APIRouter(prefix="/api/personas", tags=["personas"]) @@ -204,4 +205,16 @@ async def validate_content_endpoint( except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}") + +@router.put("/platform/{platform}") +async def update_platform_persona_endpoint( + platform: str, + update_data: Dict[str, Any], + user_id: int = Query(1, description="User ID") +): + """Update platform-specific persona fields for a user. + + Allows editing persona fields in the UI and saving them to the database. + """ + return await update_platform_persona(user_id, platform, update_data) \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index b40b0ed1..5687d0dc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -60,6 +60,7 @@ from api.facebook_writer.routers import facebook_router from routers.linkedin import router as linkedin_router # Import LinkedIn image generation router from api.linkedin_image_generation import router as linkedin_image_router +from api.brainstorm import router as brainstorm_router # Import hallucination detector router from api.hallucination_detector import router as hallucination_detector_router @@ -446,6 +447,7 @@ app.include_router(facebook_router) app.include_router(linkedin_router) # Include LinkedIn image generation router app.include_router(linkedin_image_router) +app.include_router(brainstorm_router) # Include hallucination detector router app.include_router(hallucination_detector_router) diff --git a/backend/models/__pycache__/linkedin_models.cpython-313.pyc b/backend/models/__pycache__/linkedin_models.cpython-313.pyc deleted file mode 100644 index 3e1115a0..00000000 Binary files a/backend/models/__pycache__/linkedin_models.cpython-313.pyc and /dev/null differ diff --git a/backend/models/linkedin_models.py b/backend/models/linkedin_models.py index cc4d3693..bd6ce5bc 100644 --- a/backend/models/linkedin_models.py +++ b/backend/models/linkedin_models.py @@ -61,6 +61,8 @@ class LinkedInPostRequest(BaseModel): max_length: int = Field(default=3000, description="Maximum character count", ge=100, le=3000) grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding") include_citations: bool = Field(default=True, description="Whether to include inline citations") + user_id: Optional[int] = Field(default=1, description="User id for persona lookup") + persona_override: Optional[Dict[str, Any]] = Field(default=None, description="Session-only persona overrides to apply without saving") class Config: schema_extra = { @@ -96,6 +98,8 @@ class LinkedInArticleRequest(BaseModel): word_count: int = Field(default=1500, description="Target word count", ge=500, le=5000) grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding") include_citations: bool = Field(default=True, description="Whether to include inline citations") + user_id: Optional[int] = Field(default=1, description="User id for persona lookup") + persona_override: Optional[Dict[str, Any]] = Field(default=None, description="Session-only persona overrides to apply without saving") class Config: schema_extra = { diff --git a/backend/services/linkedin/content_generator.py b/backend/services/linkedin/content_generator.py index 904c4830..42167370 100644 --- a/backend/services/linkedin/content_generator.py +++ b/backend/services/linkedin/content_generator.py @@ -21,6 +21,7 @@ from services.linkedin.content_generator_prompts import ( CarouselGenerator, VideoScriptGenerator ) +from services.persona_analysis_service import PersonaAnalysisService class ContentGenerator: @@ -340,8 +341,27 @@ class ContentGenerator: logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider") raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider") - # Build the prompt for grounded generation using the new prompt builder - prompt = PostPromptBuilder.build_post_prompt(request) + # Build the prompt for grounded generation using persona if available (DB vs session override) + persona_service = PersonaAnalysisService() + persona_data = persona_service.get_persona_for_platform(user_id=getattr(request, 'user_id', 1), platform='linkedin') if hasattr(request, 'user_id') else None + if getattr(request, 'persona_override', None): + try: + # Merge shallowly: override core and platform adaptation parts + override = request.persona_override + if persona_data: + core = persona_data.get('core_persona', {}) + platform_adapt = persona_data.get('platform_adaptation', {}) + if 'core_persona' in override: + core.update(override['core_persona']) + if 'platform_adaptation' in override: + platform_adapt.update(override['platform_adaptation']) + persona_data['core_persona'] = core + persona_data['platform_adaptation'] = platform_adapt + else: + persona_data = override + except Exception: + pass + prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data) # Generate grounded content using native Google Search grounding result = await self.gemini_grounded.generate_grounded_content( @@ -395,8 +415,26 @@ class ContentGenerator: logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider") raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider") - # Build the prompt for grounded generation using the new prompt builder - prompt = ArticlePromptBuilder.build_article_prompt(request) + # Build the prompt for grounded generation using persona if available (DB vs session override) + persona_service = PersonaAnalysisService() + persona_data = persona_service.get_persona_for_platform(user_id=getattr(request, 'user_id', 1), platform='linkedin') if hasattr(request, 'user_id') else None + if getattr(request, 'persona_override', None): + try: + override = request.persona_override + if persona_data: + core = persona_data.get('core_persona', {}) + platform_adapt = persona_data.get('platform_adaptation', {}) + if 'core_persona' in override: + core.update(override['core_persona']) + if 'platform_adaptation' in override: + platform_adapt.update(override['platform_adaptation']) + persona_data['core_persona'] = core + persona_data['platform_adaptation'] = platform_adapt + else: + persona_data = override + except Exception: + pass + prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data) # Generate grounded content using native Google Search grounding result = await self.gemini_grounded.generate_grounded_content( diff --git a/backend/services/linkedin/content_generator_prompts/article_prompts.py b/backend/services/linkedin/content_generator_prompts/article_prompts.py index 3938523c..2aecc924 100644 --- a/backend/services/linkedin/content_generator_prompts/article_prompts.py +++ b/backend/services/linkedin/content_generator_prompts/article_prompts.py @@ -4,14 +4,14 @@ LinkedIn Article Generation Prompts This module contains prompt templates and builders for generating LinkedIn articles. """ -from typing import Any +from typing import Any, Optional, Dict class ArticlePromptBuilder: """Builder class for LinkedIn article generation prompts.""" @staticmethod - def build_article_prompt(request: Any) -> str: + def build_article_prompt(request: Any, persona: Optional[Dict[str, Any]] = None) -> str: """ Build prompt for article generation. @@ -21,6 +21,27 @@ class ArticlePromptBuilder: Returns: Formatted prompt string for article generation """ + persona_block = "" + if persona: + try: + core = persona.get('core_persona', persona) + platform_adaptation = persona.get('platform_adaptation', persona.get('platform_persona', {})) + linguistic = core.get('linguistic_fingerprint', {}) + sentence_metrics = linguistic.get('sentence_metrics', {}) + lexical_features = linguistic.get('lexical_features', {}) + tonal_range = core.get('tonal_range', {}) + persona_block = f""" + PERSONA CONTEXT: + - Persona Name: {core.get('persona_name', 'N/A')} + - Archetype: {core.get('archetype', 'N/A')} + - Core Belief: {core.get('core_belief', 'N/A')} + - Default Tone: {tonal_range.get('default_tone', request.tone)} + - Avg Sentence Length: {sentence_metrics.get('average_sentence_length_words', 18)} words + - Go-to Words: {', '.join(lexical_features.get('go_to_words', [])[:5])} + """.rstrip() + except Exception: + persona_block = "" + prompt = f""" You are a senior content strategist and industry expert specializing in {request.industry}. Create a comprehensive, thought-provoking LinkedIn article that establishes authority, drives engagement, and provides genuine value to professionals in this field. @@ -30,6 +51,8 @@ class ArticlePromptBuilder: TARGET AUDIENCE: {request.target_audience or 'Industry professionals, executives, and thought leaders'} WORD COUNT: {request.word_count} words + {persona_block} + CONTENT STRUCTURE: - Compelling headline that promises specific value - Engaging introduction with a hook and clear value proposition diff --git a/backend/services/linkedin/content_generator_prompts/post_prompts.py b/backend/services/linkedin/content_generator_prompts/post_prompts.py index 133db45b..daed9998 100644 --- a/backend/services/linkedin/content_generator_prompts/post_prompts.py +++ b/backend/services/linkedin/content_generator_prompts/post_prompts.py @@ -4,14 +4,14 @@ LinkedIn Post Generation Prompts This module contains prompt templates and builders for generating LinkedIn posts. """ -from typing import Any +from typing import Any, Optional, Dict class PostPromptBuilder: """Builder class for LinkedIn post generation prompts.""" @staticmethod - def build_post_prompt(request: Any) -> str: + def build_post_prompt(request: Any, persona: Optional[Dict[str, Any]] = None) -> str: """ Build prompt for post generation. @@ -21,6 +21,33 @@ class PostPromptBuilder: Returns: Formatted prompt string for post generation """ + persona_block = "" + if persona: + try: + # Expecting structure similar to persona_service.get_persona_for_platform output + core = persona.get('core_persona', persona) + platform_adaptation = persona.get('platform_adaptation', persona.get('platform_persona', {})) + linguistic = core.get('linguistic_fingerprint', {}) + sentence_metrics = linguistic.get('sentence_metrics', {}) + lexical_features = linguistic.get('lexical_features', {}) + rhetorical_devices = linguistic.get('rhetorical_devices', {}) + tonal_range = core.get('tonal_range', {}) + + persona_block = f""" + PERSONA CONTEXT: + - Persona Name: {core.get('persona_name', 'N/A')} + - Archetype: {core.get('archetype', 'N/A')} + - Core Belief: {core.get('core_belief', 'N/A')} + - Tone: {tonal_range.get('default_tone', request.tone)} + - Sentence Length (avg): {sentence_metrics.get('average_sentence_length_words', 15)} words + - Preferred Sentence Type: {sentence_metrics.get('preferred_sentence_type', 'simple and compound')} + - Go-to Words: {', '.join(lexical_features.get('go_to_words', [])[:5])} + - Avoid Words: {', '.join(lexical_features.get('avoid_words', [])[:5])} + - Rhetorical Style: {rhetorical_devices.get('summary','balanced rhetorical questions and examples')} + """.rstrip() + except Exception: + persona_block = "" + prompt = f""" You are an expert LinkedIn content strategist with 10+ years of experience in the {request.industry} industry. Create a highly engaging, professional LinkedIn post that drives meaningful engagement and establishes thought leadership. @@ -30,6 +57,8 @@ class PostPromptBuilder: TARGET AUDIENCE: {request.target_audience or 'Industry professionals, decision-makers, and thought leaders'} MAX LENGTH: {request.max_length} characters + {persona_block} + CONTENT REQUIREMENTS: - Start with a compelling hook that addresses a pain point or opportunity - Include 2-3 specific, actionable insights or data points diff --git a/backend/services/llm_providers/gemini_grounded_provider.py b/backend/services/llm_providers/gemini_grounded_provider.py index 6fde7343..24d07854 100644 --- a/backend/services/llm_providers/gemini_grounded_provider.py +++ b/backend/services/llm_providers/gemini_grounded_provider.py @@ -396,9 +396,7 @@ class GeminiGroundedProvider: logger.error(f"First candidate structure: {dir(candidates[0]) if candidates else 'No candidates'}") raise ValueError("No grounding metadata found - grounding is not working properly") else: - logger.error("❌ CRITICAL: No candidates found in response") - logger.error(f"Response structure: {dir(response)}") - raise ValueError("No candidates found in response - grounding is not working properly") + logger.warning("⚠️ No candidates found in response. Returning content without sources.") # Add content-specific processing if content_type == "linkedin_post": diff --git a/docs/persona/PERSONA_USER_GUIDE.md b/docs/persona/PERSONA_USER_GUIDE.md index d7a75b7e..7bb39291 100644 --- a/docs/persona/PERSONA_USER_GUIDE.md +++ b/docs/persona/PERSONA_USER_GUIDE.md @@ -94,7 +94,7 @@ When using Facebook writer, you'll have access to: Your persona integrates with CopilotKit to provide intelligent, contextual assistance: #### **Contextual Conversations** -- **Persona-Aware Responses**: The AI understands your writing style and preferences +- **Persona-Aware Responses**: The AI understands your writing style and preferences - **Platform-Specific Suggestions**: Recommendations tailored to the platform you're using - **Real-Time Optimization**: Live suggestions for improving your content - **Interactive Guidance**: Step-by-step assistance for content creation diff --git a/frontend/src/components/LinkedInWriter/LinkedInWriter.tsx b/frontend/src/components/LinkedInWriter/LinkedInWriter.tsx index 1ff1cf69..0b3aa07e 100644 --- a/frontend/src/components/LinkedInWriter/LinkedInWriter.tsx +++ b/frontend/src/components/LinkedInWriter/LinkedInWriter.tsx @@ -6,7 +6,7 @@ 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, CopilotActions } from './components'; import { useLinkedInWriter } from './hooks/useLinkedInWriter'; import { useCopilotPersistence } from './utils/enhancedPersistence'; import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider'; @@ -83,11 +83,9 @@ const LinkedInWriterContent: React.FC = ({ className = '' } // Get persona context for enhanced AI assistance const { corePersona, platformPersona, loading: personaLoading } = usePlatformPersonaContext(); - // Get enhanced persistence functionality const { persistenceManager, - copilotContext, saveChatHistory, loadChatHistory, addChatMessage, @@ -227,171 +225,17 @@ const LinkedInWriterContent: React.FC = ({ className = '' } } }); - // Allow Copilot to edit the draft with specific operations - useCopilotActionTyped({ - 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'] } - ], - handler: async ({ operation }: { operation: string }) => { - const currentDraft = draft || ''; - if (!currentDraft) { - return { success: false, message: 'No draft content to edit' }; - } - let editedContent = currentDraft; - - switch (operation) { - case 'Casual': - editedContent = currentDraft.replace(/\b(utilize|implement|facilitate|leverage)\b/gi, (match) => { - const casual = { utilize: 'use', implement: 'put in place', facilitate: 'help', leverage: 'use' }; - return casual[match.toLowerCase() as keyof typeof casual] || match; - }); - editedContent = editedContent.replace(/\./g, '! 😊'); - break; - - case 'Professional': - editedContent = currentDraft.replace(/\b(use|put in place|help)\b/gi, (match) => { - const professional = { use: 'utilize', 'put in place': 'implement', help: 'facilitate' }; - return professional[match.toLowerCase() as keyof typeof professional] || match; - }); - editedContent = editedContent.replace(/! 😊/g, '.'); - break; - - case 'TightenHook': - const lines = currentDraft.split('\n'); - if (lines.length > 0) { - const firstLine = lines[0]; - const tightened = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine; - lines[0] = tightened; - editedContent = lines.join('\n'); - } - break; - - case 'AddCTA': - if (!/\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(currentDraft)) { - editedContent = currentDraft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!'; - } - break; - - case 'Shorten': - if (currentDraft.length > 200) { - editedContent = currentDraft.substring(0, 200) + '...'; - } - break; - - case 'Lengthen': - if (currentDraft.length < 500) { - 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; - - default: - return { success: false, message: 'Unknown operation' }; - } - - // Use the edit action to show the diff preview - window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { - detail: { target: editedContent } - })); - - return { success: true, message: `Draft ${operation.toLowerCase()} applied`, content: editedContent }; - } + // Initialize CopilotActions component to handle all copilot-related functionality + const getIntelligentSuggestions = CopilotActions({ + draft, + context, + userPreferences, + justGeneratedContent, + handleContextChange, + setDraft }); - // Intelligent, stage-aware suggestions (memoized to prevent infinite re-rendering) - const getIntelligentSuggestions = useMemo(() => { - const hasContent = draft && draft.trim().length > 0; - const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || ''); - const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || ''); - const isLong = (draft || '').length > 500; - - // Debug logging for suggestions - if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', { - hasContent, - justGeneratedContent, - draftLength: draft?.length || 0 - }); - - if (!hasContent) { - // Initial suggestions for content creation - const initialSuggestions = [ - { 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.' } - ]; - console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions); - return initialSuggestions; - } else { - // Refinement suggestions for existing content - use direct edit actions - const refinementSuggestions = [ - { 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: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' }, - { title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' } - ]; - - // Add special suggestions when content was just generated - if (justGeneratedContent) { - console.log('[LinkedIn Writer] Adding post-generation suggestions'); - refinementSuggestions.unshift( - { - title: '🎉 Content Generated! Next Steps:', - message: 'Great! Your content is ready. Now let\'s enhance it with images and make it perfect for LinkedIn.' - }, - { - title: '🖼️ Generate Post Image', - message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post' - } - ); - } - - // Add contextual suggestions based on content analysis - if (!hasCTA) { - refinementSuggestions.push({ title: '📣 Add CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' }); - } - if (!hasHashtags) { - refinementSuggestions.push({ title: '🏷️ Add hashtags', message: 'Use tool addLinkedInHashtags' }); - } - if (isLong) { - refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' }); - } - - // 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'); - // Make image generation suggestion more prominent - refinementSuggestions.push({ - title: '🖼️ Generate Post Image', - message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post' - }); - - // Add contextual image suggestions based on content type - if (draft.includes('digital transformation') || draft.includes('technology') || draft.includes('innovation')) { - refinementSuggestions.push({ - title: '🚀 Tech-Focused Image', - message: 'Use tool generateLinkedInImagePrompts to create technology-themed professional images for this post' - }); - } else if (draft.includes('business') || draft.includes('strategy') || draft.includes('growth')) { - refinementSuggestions.push({ - title: '💼 Business Image', - message: 'Use tool generateLinkedInImagePrompts to create business-focused professional images for this post' - }); - } - } - - if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions); - return refinementSuggestions; - } - }, [draft, justGeneratedContent]); - return (
{/* Header */} @@ -418,6 +262,7 @@ const LinkedInWriterContent: React.FC = ({ className = '' }
+ {/* Debug: Enhanced Persistence Test Buttons (remove in production) */} @@ -470,6 +315,7 @@ const LinkedInWriterContent: React.FC = ({ className = '' } {/* Enhanced Persona-Aware Actions */} + {/* CopilotKit Sidebar */} { + return data && + Array.isArray(data.ideas) && + Array.isArray(data.searchResults) && + typeof data.timestamp === 'number'; +}; + +interface BrainstormFlowProps { + brainstormVisible: boolean; + setBrainstormVisible: React.Dispatch>; + brainstormStage: 'loading' | 'select' | 'results'; + setBrainstormStage: React.Dispatch>; + loaderMessageIndex: number; + setLoaderMessageIndex: React.Dispatch>; + aiSearchPrompts: string[]; + setAiSearchPrompts: React.Dispatch>; + selectedPrompt: string; + setSelectedPrompt: React.Dispatch>; + searchResults: any[]; + setSearchResults: React.Dispatch>; + ideas: { prompt: string; rationale?: string }[]; + setIdeas: React.Dispatch>; + isUsingCache: boolean; + setIsUsingCache: React.Dispatch>; +} + +const BrainstormFlow: React.FC = ({ + brainstormVisible, + setBrainstormVisible, + brainstormStage, + setBrainstormStage, + loaderMessageIndex, + setLoaderMessageIndex, + aiSearchPrompts, + setAiSearchPrompts, + selectedPrompt, + setSelectedPrompt, + searchResults, + setSearchResults, + ideas, + setIdeas, + isUsingCache, + setIsUsingCache +}) => { + const { corePersona, platformPersona } = usePlatformPersonaContext(); + + const loaderMessages = useMemo(() => ([ + 'Searching the web for the most recent and relevant coverage...', + 'Extracting entities and context from top sources...', + 'Aligning findings with your persona and audience...', + 'Formulating high-signal brainstorm prompts you can use right away...' + ]), []); + + // Cache management utilities + const getCacheKey = useCallback((seed: string, personaId?: string, platformPersonaId?: string) => { + return `brainstorm_ideas_${seed}_${personaId || 'default'}_${platformPersonaId || 'default'}`; + }, []); + + const getCachedIdeas = useCallback((cacheKey: string): BrainstormCacheData | null => { + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const data = JSON.parse(cached); + if (isBrainstormCacheData(data)) { + // Check if cache is less than 1 hour old + if (Date.now() - data.timestamp < 3600000) { + return data; + } else { + sessionStorage.removeItem(cacheKey); + } + } + } + } catch (e) { + console.warn('Failed to read brainstorm cache:', e); + } + return null; + }, []); + + const setCachedIdeas = useCallback((cacheKey: string, ideas: any[], searchResults: any[]) => { + try { + const cacheData = { + ideas, + searchResults, + timestamp: Date.now() + }; + sessionStorage.setItem(cacheKey, JSON.stringify(cacheData)); + } catch (e) { + console.warn('Failed to cache brainstorm ideas:', e); + } + }, []); + + const clearCache = useCallback(() => { + try { + const keys = Object.keys(sessionStorage); + keys.forEach(key => { + if (key.startsWith('brainstorm_ideas_')) { + sessionStorage.removeItem(key); + } + }); + } catch (e) { + console.warn('Failed to clear brainstorm cache:', e); + } + }, []); + + React.useEffect(() => { + const handler = async (ev: any) => { + try { + // Store the event for refresh functionality + (window as any).lastBrainstormEvent = ev; + + const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {}; + const finalSeed = ideaSeed || prompt; + + setBrainstormVisible(true); + setBrainstormStage('loading'); + setLoaderMessageIndex(0); + + // Special case: show most recent cached ideas when seed is 'cached' + if (finalSeed === 'cached') { + try { + const keys = Object.keys(sessionStorage); + let mostRecentCache: BrainstormCacheData | null = null; + let mostRecentKey = ''; + let mostRecentTimestamp = 0; + + for (const key of keys) { + if (key.startsWith('brainstorm_ideas_')) { + const cached = sessionStorage.getItem(key); + if (cached) { + const data = JSON.parse(cached); + if (isBrainstormCacheData(data) && data.timestamp > mostRecentTimestamp && data.ideas.length > 0) { + mostRecentTimestamp = data.timestamp; + mostRecentCache = data; + mostRecentKey = key; + } + } + } + } + + if (mostRecentCache !== null) { + console.log('Showing most recent cached brainstorm ideas from:', mostRecentKey); + setIdeas(mostRecentCache.ideas); + setAiSearchPrompts(mostRecentCache.ideas.map((x) => x.prompt)); + setSelectedPrompt(mostRecentCache.ideas[0]?.prompt || ''); + setSearchResults(mostRecentCache.searchResults || []); + setIsUsingCache(true); + setBrainstormStage('select'); + return; + } else { + // No cached ideas found, close modal + setBrainstormVisible(false); + return; + } + } catch (e) { + console.warn('Failed to load cached ideas:', e); + setBrainstormVisible(false); + return; + } + } + + // Check cache first (unless force refresh) + const personaId = corePersona?.id?.toString(); + const platformPersonaId = platformPersona?.id?.toString(); + const cacheKey = getCacheKey(finalSeed, personaId, platformPersonaId); + + if (!forceRefresh) { + const cached = getCachedIdeas(cacheKey); + if (cached) { + console.log('Using cached brainstorm ideas for:', finalSeed); + setIdeas(cached.ideas); + setAiSearchPrompts(cached.ideas.map((x) => x.prompt)); + setSelectedPrompt(cached.ideas[0]?.prompt || ''); + setSearchResults(cached.searchResults || []); + setIsUsingCache(true); + setBrainstormStage('select'); + return; + } + } + + setIsUsingCache(false); + + // Gentle loader progression + let step = 0; + const interval = setInterval(() => { + step += 1; + setLoaderMessageIndex((idx: number) => Math.min(idx + 1, loaderMessages.length - 1)); + if (step >= loaderMessages.length - 1) clearInterval(interval); + }, 700); + + // First: run grounded search for the seed prompt + let results: any[] = []; + try { + const sr = await fetch('/api/brainstorm/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: finalSeed }) + }); + if (sr.ok) { + const data = await sr.json(); + results = data?.results || []; + } + } catch {} + setSearchResults(results); + + // Then: request persona-aware brainstorm ideas using the search results + try { + const ir = await fetch('/api/brainstorm/ideas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + seed: finalSeed, + persona: corePersona || null, + platformPersona: platformPersona || null, + results, + count: 5 + }) + }); + if (ir.ok) { + const data = await ir.json(); + const list = Array.isArray(data?.ideas) ? data.ideas : []; + setIdeas(list); + setAiSearchPrompts(list.map((x: any) => x.prompt)); + setSelectedPrompt(list[0]?.prompt || ''); + + // Cache the results + setCachedIdeas(cacheKey, list, results); + console.log('Cached brainstorm ideas for:', finalSeed); + } else { + setIdeas([]); + } + } catch { + setIdeas([]); + } + + setBrainstormStage('select'); + } catch (e) { + console.error('Brainstorm flow error:', e); + setBrainstormVisible(false); + } + }; + window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler); + return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler); + }, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]); + + return ( + <> + {/* Brainstorm Flow UI */} + {brainstormVisible && ( +
+
+ {/* Fixed Header */} +
+
Brainstorm: Google Search Prompts
+
+ + + +
+
+ + {/* Scrollable Content */} +
+ {brainstormStage === 'loading' && ( +
+
+
+
+
Preparing Google search prompts
+
{loaderMessages[loaderMessageIndex]}
+
+
+
    +
  • 1/4 Persona-aware analysis
  • +
  • 2/4 Seed expansion and entities
  • +
  • 3/4 Grounding and timeliness checks
  • +
  • 4/4 Output assembly
  • +
+ +
+ )} + + {brainstormStage === 'select' && ( +
+
+ Select one prompt to run with Google Search + {isUsingCache && ( + + 📦 Cached + + )} +
+
+ {aiSearchPrompts.map((p, i) => { + const rationale = ideas[i]?.rationale; + return ( + + ); + })} +
+
+ )} + + {brainstormStage === 'results' && ( +
+
Search Results
+ {searchResults.length === 0 ? ( +
No results or search unavailable. Try another prompt.
+ ) : ( +
+ {searchResults.map((r: any, idx: number) => ( +
+
{r.title || r.name || 'Result'}
+
{r.snippet || r.description || r.content || ''}
+ {r.url && ()} +
+ ))} +
+ )} +
+ )} +
+ + {/* Fixed Footer */} + {brainstormStage !== 'loading' && ( +
+ {brainstormStage === 'select' && ( + <> + + + + + )} + + {brainstormStage === 'results' && ( + <> + + + + + )} +
+ )} +
+
+ )} + + ); +}; + +export default BrainstormFlow; diff --git a/frontend/src/components/LinkedInWriter/components/CopilotActions.tsx b/frontend/src/components/LinkedInWriter/components/CopilotActions.tsx new file mode 100644 index 00000000..69c58ea1 --- /dev/null +++ b/frontend/src/components/LinkedInWriter/components/CopilotActions.tsx @@ -0,0 +1,432 @@ +import React, { useMemo } from 'react'; +import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core'; +import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider'; + +const useCopilotActionTyped = useCopilotAction as any; + +// Optional debug flag: set to true to enable verbose logs locally +const DEBUG_LINKEDIN = false; + +interface CopilotActionsProps { + draft: string; + context: string; + userPreferences: any; + justGeneratedContent: boolean; + handleContextChange: (value: string) => void; + setDraft: (draft: string) => void; +} + +// Note: This is implemented as a hook-like utility, not a rendered component. +// It returns the getIntelligentSuggestions function for use by the caller. +const CopilotActions = ({ + draft, + context, + userPreferences, + justGeneratedContent, + handleContextChange, + setDraft +}: CopilotActionsProps) => { + const { corePersona, platformPersona } = usePlatformPersonaContext(); + const copilotContext = useCopilotContext(); + + // Listen for copilot seed events to open sidebar with prompt + React.useEffect(() => { + const handler = (ev: any) => { + try { + const { prompt } = ev.detail || {}; + if (!prompt) return; + + // First, open the copilot sidebar + const copilotButton = document.querySelector('.copilotkit-open-button') || + document.querySelector('[data-copilot-open]') || + document.querySelector('button[aria-label*="Open"]') || + document.querySelector('.alwrity-copilot-sidebar button') || + document.querySelector('[data-testid="copilot-open-button"]'); + + if (copilotButton) { + (copilotButton as HTMLElement).click(); + + // Try context-based approach first (if available) + if (copilotContext && typeof copilotContext === 'object') { + try { + // Check if context has any message sending capabilities + if ('sendMessage' in copilotContext && typeof copilotContext.sendMessage === 'function') { + setTimeout(() => { + (copilotContext as any).sendMessage(prompt); + console.log('Message sent via context'); + return; + }, 500); + } + } catch (e) { + console.log('Context-based approach failed, falling back to DOM manipulation'); + } + } + + // Alternative: Try to trigger the generateFromPrompt action directly + setTimeout(() => { + // Try to find and trigger the generateFromPrompt action + const actionButton = document.querySelector('[data-action="generateFromPrompt"]') || + 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; + (actionButton as HTMLElement).click(); + console.log('Triggered generateFromPrompt action with:', prompt); + return; + } + }, 200); + + // Fallback: Wait a bit for the sidebar to open, then set the input value + setTimeout(() => { + // Try multiple selectors for the chat input + const chatInput = document.querySelector('.copilotkit-chat-input') || + document.querySelector('textarea[placeholder*="message"]') || + document.querySelector('input[placeholder*="message"]') || + document.querySelector('.copilot-chat-input') || + document.querySelector('[data-testid="chat-input"]') || + document.querySelector('textarea[data-testid="chat-input"]') || + document.querySelector('.copilotkit-chat-input textarea') || + document.querySelector('.copilotkit-chat-input input') || + document.querySelector('textarea[data-copilot-input]') || + document.querySelector('input[data-copilot-input]'); + + if (chatInput) { + const inputElement = chatInput as HTMLInputElement | HTMLTextAreaElement; + + // Check if input is disabled or read-only + if (inputElement.disabled || inputElement.readOnly) { + console.warn('Input is disabled or read-only, attempting to enable it'); + inputElement.disabled = false; + inputElement.readOnly = false; + inputElement.removeAttribute('disabled'); + inputElement.removeAttribute('readonly'); + } + + // Clear any existing value first + inputElement.value = ''; + + // Set the new value + inputElement.value = prompt; + + // Focus the input + inputElement.focus(); + + // Trigger multiple events to ensure React state updates + const inputEvent = new Event('input', { bubbles: true, cancelable: true }); + const changeEvent = new Event('change', { bubbles: true, cancelable: true }); + const keyupEvent = new Event('keyup', { bubbles: true, cancelable: true }); + + // Set the target property for React synthetic events + Object.defineProperty(inputEvent, 'target', { value: inputElement, enumerable: true }); + Object.defineProperty(changeEvent, 'target', { value: inputElement, enumerable: true }); + Object.defineProperty(keyupEvent, 'target', { value: inputElement, enumerable: true }); + + // Dispatch events in sequence + inputElement.dispatchEvent(inputEvent); + inputElement.dispatchEvent(changeEvent); + inputElement.dispatchEvent(keyupEvent); + + // Try to trigger a React synthetic event with more properties + const syntheticEvent = new Event('input', { bubbles: true, cancelable: true }); + Object.defineProperty(syntheticEvent, 'target', { value: inputElement, enumerable: true }); + Object.defineProperty(syntheticEvent, 'currentTarget', { value: inputElement, enumerable: true }); + Object.defineProperty(syntheticEvent, 'nativeEvent', { value: syntheticEvent, enumerable: true }); + inputElement.dispatchEvent(syntheticEvent); + + // Also try to trigger a focus event to ensure the input is active + const focusEvent = new Event('focus', { bubbles: true, cancelable: true }); + inputElement.dispatchEvent(focusEvent); + + // Try to find and enable the send button if it exists + setTimeout(() => { + const sendButton = document.querySelector('button[type="submit"]') || + document.querySelector('button[data-copilot-send]') || + document.querySelector('.copilotkit-send-button') || + document.querySelector('button[aria-label*="Send"]') || + document.querySelector('button[title*="Send"]'); + + if (sendButton) { + // Remove disabled attribute if it exists + (sendButton as HTMLButtonElement).disabled = false; + (sendButton as HTMLButtonElement).removeAttribute('disabled'); + console.log('Send button enabled'); + + // Try to automatically send the message after a short delay + setTimeout(() => { + if (!(sendButton as HTMLButtonElement).disabled) { + (sendButton as HTMLButtonElement).click(); + console.log('Message sent automatically'); + } + }, 500); + + // Alternative: Try to simulate Enter key press + setTimeout(() => { + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + }); + inputElement.dispatchEvent(enterEvent); + + const enterUpEvent = new KeyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + }); + inputElement.dispatchEvent(enterUpEvent); + console.log('Enter key simulated'); + }, 600); + } + }, 100); + + console.log('Copilot sidebar opened with prompt:', prompt); + console.log('Input element details:', { + value: inputElement.value, + disabled: inputElement.disabled, + readOnly: inputElement.readOnly, + className: inputElement.className, + id: inputElement.id + }); + } else { + console.warn('Could not find copilot chat input to prefill. Available elements:', + Array.from(document.querySelectorAll('textarea, input')).map(el => ({ + tag: el.tagName, + className: el.className, + placeholder: el.getAttribute('placeholder'), + id: el.id, + 'data-copilot-input': el.getAttribute('data-copilot-input') + })) + ); + } + }, 1000); // Increased timeout to ensure sidebar is fully loaded + } else { + console.warn('Could not find copilot sidebar button to open. Available buttons:', + Array.from(document.querySelectorAll('button')).map(btn => ({ + className: btn.className, + text: btn.textContent?.trim(), + 'aria-label': btn.getAttribute('aria-label') + })) + ); + } + } catch (e) { + console.error('Error handling copilot seed event:', e); + } + }; + + window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler); + return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler); + }, []); + + // Allow external prompts to trigger content generation + useCopilotActionTyped({ + name: 'generateFromPrompt', + description: 'Generate LinkedIn content from a specific prompt or idea', + parameters: [ + { name: 'prompt', type: 'string', description: 'The prompt or idea to generate content from', required: true } + ], + handler: async ({ prompt }: { prompt: string }) => { + // Check for temporary prompt from brainstorm flow + const finalPrompt = prompt || (window as any).tempPromptForGeneration; + + if (!finalPrompt) { + return { success: false, message: 'No prompt provided' }; + } + + // Clear the temporary prompt + if ((window as any).tempPromptForGeneration) { + delete (window as any).tempPromptForGeneration; + } + + // Set the prompt as context and trigger generation + handleContextChange(finalPrompt); + + // Use the existing LinkedIn post generation action + try { + // This will trigger the existing generateLinkedInPost action + return { + success: true, + message: `Generating LinkedIn content from prompt: "${finalPrompt}"`, + prompt: finalPrompt + }; + } catch (error) { + return { success: false, message: 'Failed to generate content from prompt' }; + } + } + }); + + // Allow Copilot to edit the draft with specific operations + useCopilotActionTyped({ + 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'] } + ], + handler: async ({ operation }: { operation: string }) => { + const currentDraft = draft || ''; + if (!currentDraft) { + return { success: false, message: 'No draft content to edit' }; + } + + let editedContent = currentDraft; + + switch (operation) { + case 'Casual': + editedContent = currentDraft.replace(/\b(utilize|implement|facilitate|leverage)\b/gi, (match) => { + const casual = { utilize: 'use', implement: 'put in place', facilitate: 'help', leverage: 'use' }; + return casual[match.toLowerCase() as keyof typeof casual] || match; + }); + editedContent = editedContent.replace(/\./g, '! 😊'); + break; + + case 'Professional': + editedContent = currentDraft.replace(/\b(use|put in place|help)\b/gi, (match) => { + const professional = { use: 'utilize', 'put in place': 'implement', help: 'facilitate' }; + return professional[match.toLowerCase() as keyof typeof professional] || match; + }); + editedContent = editedContent.replace(/! 😊/g, '.'); + break; + + case 'TightenHook': + const lines = currentDraft.split('\n'); + if (lines.length > 0) { + const firstLine = lines[0]; + const tightened = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine; + lines[0] = tightened; + editedContent = lines.join('\n'); + } + break; + + case 'AddCTA': + if (!/\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(currentDraft)) { + editedContent = currentDraft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!'; + } + break; + + case 'Shorten': + if (currentDraft.length > 200) { + editedContent = currentDraft.substring(0, 200) + '...'; + } + break; + + case 'Lengthen': + if (currentDraft.length < 500) { + 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; + + default: + return { success: false, message: 'Unknown operation' }; + } + + // Use the edit action to show the diff preview + window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { + detail: { target: editedContent } + })); + + return { success: true, message: `Draft ${operation.toLowerCase()} applied`, content: editedContent }; + } + }); + + // Intelligent, stage-aware suggestions (memoized to prevent infinite re-rendering) + const getIntelligentSuggestions = useMemo(() => { + const hasContent = draft && draft.trim().length > 0; + const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || ''); + const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || ''); + const isLong = (draft || '').length > 500; + + // Debug logging for suggestions + if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', { + hasContent, + justGeneratedContent, + draftLength: draft?.length || 0 + }); + + if (!hasContent) { + // Initial suggestions for content creation + const initialSuggestions = [ + { 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.' } + ]; + console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions); + return initialSuggestions; + } else { + // Refinement suggestions for existing content - use direct edit actions + const refinementSuggestions = [ + { 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: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' }, + { title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' } + ]; + + // Add special suggestions when content was just generated + if (justGeneratedContent) { + console.log('[LinkedIn Writer] Adding post-generation suggestions'); + refinementSuggestions.unshift( + { + title: '🎉 Content Generated! Next Steps:', + message: 'Great! Your content is ready. Now let\'s enhance it with images and make it perfect for LinkedIn.' + }, + { + title: '🖼️ Generate Post Image', + message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post' + } + ); + } + + // Add contextual suggestions based on content analysis + if (!hasCTA) { + refinementSuggestions.push({ title: '📣 Add CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' }); + } + if (!hasHashtags) { + refinementSuggestions.push({ title: '🏷️ Add hashtags', message: 'Use tool addLinkedInHashtags' }); + } + if (isLong) { + refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' }); + } + + // 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'); + // Make image generation suggestion more prominent + refinementSuggestions.push({ + title: '🖼️ Generate Post Image', + message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post' + }); + + // Add contextual image suggestions based on content type + if (draft.includes('digital transformation') || draft.includes('technology') || draft.includes('innovation')) { + refinementSuggestions.push({ + title: '🚀 Tech-Focused Image', + message: 'Use tool generateLinkedInImagePrompts to create technology-themed professional images for this post' + }); + } else if (draft.includes('business') || draft.includes('strategy') || draft.includes('growth')) { + refinementSuggestions.push({ + title: '💼 Business Image', + message: 'Use tool generateLinkedInImagePrompts to create business-focused professional images for this post' + }); + } + } + + if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions); + return refinementSuggestions; + } + }, [draft, justGeneratedContent]); + + // Return the suggestions function directly + return getIntelligentSuggestions; +}; + +export default CopilotActions; diff --git a/frontend/src/components/LinkedInWriter/components/Header.tsx b/frontend/src/components/LinkedInWriter/components/Header.tsx index 00fbc22b..789557ed 100644 --- a/frontend/src/components/LinkedInWriter/components/Header.tsx +++ b/frontend/src/components/LinkedInWriter/components/Header.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { LinkedInPreferences } from '../utils/storageUtils'; +import { PersonaChip } from '../../TextEditor/ContentPreviewHeaderComponents'; +import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider'; +import BrainstormFlow from './BrainstormFlow'; // Temporary fix: use require for image import const alwrityLogo = require('../../../assets/images/alwrity_logo.png'); @@ -22,10 +25,59 @@ export const Header: React.FC = ({ onClearHistory, getHistoryLength }) => { + const [personaOverride, setPersonaOverride] = useState(null); + const { corePersona, platformPersona } = usePlatformPersonaContext(); + + // Brainstorm modal state + const [showBrainstormModal, setShowBrainstormModal] = useState(false); + const [seed, setSeed] = useState(''); + const [usePersona, setUsePersona] = useState(true); + const [useGoogleSearch, setUseGoogleSearch] = useState(true); + const [includeTrending, setIncludeTrending] = useState(false); + const [remarketContent, setRemarketContent] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [aiSearchPrompt, setAiSearchPrompt] = useState(''); + + // BrainstormFlow state management + const [brainstormVisible, setBrainstormVisible] = useState(false); + const [brainstormStage, setBrainstormStage] = useState<'loading' | 'select' | 'results'>('loading'); + const [loaderMessageIndex, setLoaderMessageIndex] = useState(0); + const [aiSearchPrompts, setAiSearchPrompts] = useState([]); + const [selectedPrompt, setSelectedPrompt] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [ideas, setIdeas] = useState<{ prompt: string; rationale?: string }[]>([]); + const [isUsingCache, setIsUsingCache] = useState(false); + + // Check if there are cached brainstorm ideas + const hasCachedIdeas = useMemo(() => { + try { + const keys = Object.keys(sessionStorage); + return keys.some(key => { + if (key.startsWith('brainstorm_ideas_')) { + const cached = sessionStorage.getItem(key); + if (cached) { + const data = JSON.parse(cached); + // Check if cache is less than 1 hour old and has ideas + return Date.now() - data.timestamp < 3600000 && data.ideas && data.ideas.length > 0; + } + } + return false; + }); + } catch (e) { + return false; + } + }, [showBrainstormModal]); // Re-check when modal opens + const handlePreferenceChange = (key: keyof LinkedInPreferences, value: any) => { onPreferencesChange({ [key]: value }); }; + const handlePersonaUpdate = (personaData: any) => { + console.log('Persona updated in LinkedIn writer:', personaData); + setPersonaOverride(personaData); + // You can also save this to user preferences or pass it up to the parent component + }; + return (
= ({ cursor: 'pointer' }} onMouseEnter={() => onPreferencesModalChange(true)} - onMouseLeave={() => onPreferencesModalChange(false)} >
= ({ {/* Preferences Modal */} {showPreferencesModal && ( -
+
onPreferencesModalChange(true)} + onMouseLeave={() => onPreferencesModalChange(false)} + >

Content Preferences & Persona @@ -144,6 +199,7 @@ export const Header: React.FC = ({

+ {/* Interactive Persona Chip */}
= ({ borderRadius: '6px', border: '1px solid #e2e8f0' }}> -
- 🎭 - 🎯 -
-
-
- The Digital Strategist (The Insightful Guide) -
-
- 88% accuracy | Platform: LinkedIn Optimized -
-
+
= ({ color: '#666', fontStyle: 'italic' }}> - Hover over persona for detailed information + Click persona to edit writing style, tone, and preferences
@@ -347,7 +396,50 @@ export const Header: React.FC = ({
-
+
+ {/* Today's Tasks Button */} + + + {/* Brainstorm Ideas Button */} + + + {/* Clear Memory Button */}
+ + {/* Initial Brainstorm Modal */} + {showBrainstormModal && ( +
+
+ {/* Header */} +
+
Brainstorm LinkedIn Content Ideas
+ +
+ + {/* Body */} +
+
+
Options
+ +
+ + + + + + + +
+ +
+
+
Idea Seed (optional)
+
+