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:
@@ -36,6 +36,7 @@ class SearchEngine(str, Enum):
|
||||
METAPHOR = "metaphor"
|
||||
GOOGLE = "google"
|
||||
TAVILY = "tavily"
|
||||
EXA = "exa"
|
||||
|
||||
|
||||
class GroundingLevel(str, Enum):
|
||||
@@ -57,7 +58,7 @@ class LinkedInPostRequest(BaseModel):
|
||||
include_hashtags: bool = Field(default=True, description="Whether to include hashtags")
|
||||
include_call_to_action: bool = Field(default=True, description="Whether to include call to action")
|
||||
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
|
||||
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")
|
||||
@@ -94,7 +95,7 @@ class LinkedInArticleRequest(BaseModel):
|
||||
include_images: bool = Field(default=True, description="Whether to generate image suggestions")
|
||||
seo_optimization: bool = Field(default=True, description="Whether to include SEO optimization")
|
||||
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
|
||||
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")
|
||||
@@ -129,9 +130,11 @@ class LinkedInCarouselRequest(BaseModel):
|
||||
number_of_slides: int = Field(default=5, description="Number of slides", ge=3, le=10)
|
||||
include_cover_slide: bool = Field(default=True, description="Whether to include a cover slide")
|
||||
include_cta_slide: bool = Field(default=True, description="Whether to include a call-to-action slide")
|
||||
key_points: Optional[List[str]] = Field(None, description="Specific key points to cover", max_items=10)
|
||||
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
|
||||
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||
color_scheme: str = Field(default="professional", description="Color scheme for PDF rendering: professional, creative, industry, dark, minimal")
|
||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||
|
||||
class Config:
|
||||
@@ -144,9 +147,11 @@ class LinkedInCarouselRequest(BaseModel):
|
||||
"number_of_slides": 6,
|
||||
"include_cover_slide": True,
|
||||
"include_cta_slide": True,
|
||||
"key_points": ["Remote collaboration tools", "Work-life balance", "Productivity metrics"],
|
||||
"research_enabled": True,
|
||||
"search_engine": "google",
|
||||
"grounding_level": "enhanced",
|
||||
"color_scheme": "professional",
|
||||
"include_citations": True
|
||||
}
|
||||
}
|
||||
@@ -161,8 +166,9 @@ class LinkedInVideoScriptRequest(BaseModel):
|
||||
video_duration: int = Field(default=60, description="Target video duration in seconds", ge=30, le=300)
|
||||
include_captions: bool = Field(default=True, description="Whether to include captions")
|
||||
include_thumbnail_suggestions: bool = Field(default=True, description="Whether to include thumbnail suggestions")
|
||||
key_points: Optional[List[str]] = Field(None, description="Specific key points to cover in the video", max_items=10)
|
||||
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
|
||||
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||
|
||||
@@ -176,6 +182,7 @@ class LinkedInVideoScriptRequest(BaseModel):
|
||||
"video_duration": 90,
|
||||
"include_captions": True,
|
||||
"include_thumbnail_suggestions": True,
|
||||
"key_points": ["Zero trust architecture", "Phishing prevention", "Incident response"],
|
||||
"research_enabled": True,
|
||||
"search_engine": "google",
|
||||
"grounding_level": "enhanced",
|
||||
@@ -193,7 +200,7 @@ class LinkedInCommentResponseRequest(BaseModel):
|
||||
response_length: str = Field(default="medium", description="Length of response: short, medium, long")
|
||||
include_questions: bool = Field(default=True, description="Whether to include engaging questions")
|
||||
research_enabled: bool = Field(default=False, description="Whether to include research-backed content")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
|
||||
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
|
||||
grounding_level: GroundingLevel = Field(default=GroundingLevel.BASIC, description="Level of content grounding")
|
||||
|
||||
class Config:
|
||||
@@ -452,3 +459,23 @@ class LinkedInCommentResponseResult(BaseModel):
|
||||
generation_metadata: Dict[str, Any] = {}
|
||||
error: Optional[str] = None
|
||||
grounding_status: Optional[Dict[str, Any]] = Field(None, description="Grounding operation status")
|
||||
|
||||
|
||||
class LinkedInEditContentRequest(BaseModel):
|
||||
"""Request model for AI-powered LinkedIn content editing."""
|
||||
content: str = Field(..., description="Content to edit", min_length=1)
|
||||
edit_type: str = Field(..., description="Type of edit: professionalize, optimize_engagement, add_hashtags, adjust_tone, expand, condense, add_cta")
|
||||
industry: Optional[str] = Field(None, description="Industry context for the edit")
|
||||
tone: Optional[str] = Field(None, description="Target tone: professional, conversational, authoritative, educational, friendly")
|
||||
target_audience: Optional[str] = Field(None, description="Target audience for the content")
|
||||
parameters: Optional[Dict[str, Any]] = Field(None, description="Additional parameters specific to edit type")
|
||||
|
||||
|
||||
class LinkedInEditContentResponse(BaseModel):
|
||||
"""Response model for AI-powered LinkedIn content editing."""
|
||||
success: bool = True
|
||||
content: Optional[str] = None
|
||||
edit_type: str
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
@@ -7,9 +7,10 @@ proper error handling, monitoring, and documentation.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from typing import Dict, Any, Optional
|
||||
import time
|
||||
import json
|
||||
from loguru import logger
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,11 +18,17 @@ from models.linkedin_models import (
|
||||
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
||||
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
|
||||
LinkedInPostResponse, LinkedInArticleResponse, LinkedInCarouselResponse,
|
||||
LinkedInVideoScriptResponse, LinkedInCommentResponseResult
|
||||
LinkedInVideoScriptResponse, LinkedInCommentResponseResult,
|
||||
LinkedInEditContentRequest, LinkedInEditContentResponse
|
||||
)
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.linkedin_service import LinkedInService
|
||||
from services.linkedin.carousel import LinkedInCarouselPDFRenderer
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from utils.text_asset_tracker import save_and_track_text_content
|
||||
from models.api_monitoring import APIRequest
|
||||
from sqlalchemy import func
|
||||
from collections import defaultdict
|
||||
|
||||
# Initialize the LinkedIn service instance
|
||||
linkedin_service = LinkedInService()
|
||||
@@ -29,6 +36,34 @@ from services.subscription.monitoring_middleware import DatabaseAPIMonitor
|
||||
from services.database import get_db as get_db_dependency
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Simple in-memory rate limiter: {user_id: [timestamp, ...]}
|
||||
_rate_limit_store: Dict[str, list] = defaultdict(list)
|
||||
RATE_LIMIT_MAX_REQUESTS = 30
|
||||
RATE_LIMIT_WINDOW = 60 # seconds
|
||||
|
||||
def check_rate_limit(user_id: str) -> Optional[int]:
|
||||
"""Returns retry-after seconds if rate limited, None otherwise."""
|
||||
now = time.time()
|
||||
window_start = now - RATE_LIMIT_WINDOW
|
||||
timestamps = _rate_limit_store[user_id]
|
||||
# Prune old entries
|
||||
_rate_limit_store[user_id] = [t for t in timestamps if t > window_start]
|
||||
if len(_rate_limit_store[user_id]) >= RATE_LIMIT_MAX_REQUESTS:
|
||||
return int(_rate_limit_store[user_id][0] + RATE_LIMIT_WINDOW - now)
|
||||
_rate_limit_store[user_id].append(now)
|
||||
return None
|
||||
|
||||
ERROR_CODES = {
|
||||
'VALIDATION': 'LINKEDIN_ERR_001',
|
||||
'GENERATION_FAILED': 'LINKEDIN_ERR_002',
|
||||
'RATE_LIMITED': 'LINKEDIN_ERR_003',
|
||||
'SAVE_FAILED': 'LINKEDIN_ERR_004',
|
||||
'NOT_FOUND': 'LINKEDIN_ERR_404',
|
||||
}
|
||||
|
||||
def error_response(code: str, message: str) -> dict:
|
||||
return {"code": code, "message": message}
|
||||
|
||||
# Initialize router
|
||||
router = APIRouter(
|
||||
prefix="/api/linkedin",
|
||||
@@ -112,10 +147,10 @@ async def generate_post(
|
||||
|
||||
# Validate request
|
||||
if not request.topic.strip():
|
||||
raise HTTPException(status_code=422, detail="Topic cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
|
||||
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
|
||||
|
||||
# Extract user_id
|
||||
user_id = None
|
||||
@@ -124,22 +159,30 @@ async def generate_post(
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# Rate limit check
|
||||
retry_after = check_rate_limit(user_id or 'anonymous')
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."),
|
||||
headers={"Retry-After": str(retry_after)}
|
||||
)
|
||||
|
||||
# Generate post content
|
||||
response = await linkedin_service.generate_linkedin_post(request)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Post generation failed"))
|
||||
|
||||
# Log successful request
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 200
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
# Save and track text content
|
||||
if user_id and response.data and response.data.content:
|
||||
try:
|
||||
# Combine all text content
|
||||
text_content = response.data.content
|
||||
if response.data.call_to_action:
|
||||
text_content += f"\n\nCall to Action: {response.data.call_to_action}"
|
||||
@@ -166,7 +209,7 @@ async def generate_post(
|
||||
subdirectory="posts"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn post asset: {track_error}")
|
||||
logger.error(f"Failed to track LinkedIn post asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
|
||||
return response
|
||||
@@ -177,14 +220,13 @@ async def generate_post(
|
||||
duration = time.time() - start_time
|
||||
logger.error(f"Error generating LinkedIn post: {str(e)}")
|
||||
|
||||
# Log failed request
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 500
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate LinkedIn post: {str(e)}"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn post: {str(e)}")
|
||||
)
|
||||
|
||||
|
||||
@@ -222,10 +264,10 @@ async def generate_article(
|
||||
|
||||
# Validate request
|
||||
if not request.topic.strip():
|
||||
raise HTTPException(status_code=422, detail="Topic cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
|
||||
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
|
||||
|
||||
# Extract user_id
|
||||
user_id = None
|
||||
@@ -234,17 +276,16 @@ async def generate_article(
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# Rate limit check
|
||||
retry_after = check_rate_limit(user_id or 'anonymous')
|
||||
if retry_after:
|
||||
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
|
||||
|
||||
# Generate article content
|
||||
response = await linkedin_service.generate_linkedin_article(request)
|
||||
|
||||
# Log successful request
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 200
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Article generation failed"))
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
@@ -282,7 +323,7 @@ async def generate_article(
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn article asset: {track_error}")
|
||||
logger.error(f"Failed to track LinkedIn article asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
|
||||
return response
|
||||
@@ -300,7 +341,7 @@ async def generate_article(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate LinkedIn article: {str(e)}"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn article: {str(e)}")
|
||||
)
|
||||
|
||||
|
||||
@@ -337,13 +378,13 @@ async def generate_carousel(
|
||||
|
||||
# Validate request
|
||||
if not request.topic.strip():
|
||||
raise HTTPException(status_code=422, detail="Topic cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
|
||||
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
|
||||
|
||||
if request.slide_count < 3 or request.slide_count > 15:
|
||||
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
|
||||
if request.number_of_slides < 3 or request.number_of_slides > 15:
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Number of slides must be between 3 and 15"))
|
||||
|
||||
# Extract user_id
|
||||
user_id = None
|
||||
@@ -352,18 +393,23 @@ async def generate_carousel(
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# Rate limit check
|
||||
retry_after = check_rate_limit(user_id or 'anonymous')
|
||||
if retry_after:
|
||||
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
|
||||
|
||||
# Generate carousel content
|
||||
response = await linkedin_service.generate_linkedin_carousel(request)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Carousel generation failed"))
|
||||
|
||||
# Log successful request
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 200
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
try:
|
||||
@@ -381,10 +427,10 @@ async def generate_carousel(
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
||||
description=f"LinkedIn carousel for {request.industry} industry",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {getattr(request, 'number_of_slides', request.slide_count if hasattr(request, 'slide_count') else 5)}",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {request.number_of_slides}",
|
||||
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"slide_count": len(response.data.slides),
|
||||
"number_of_slides": len(response.data.slides),
|
||||
"has_cover": response.data.cover_slide is not None,
|
||||
"has_cta": response.data.cta_slide is not None
|
||||
},
|
||||
@@ -392,7 +438,7 @@ async def generate_carousel(
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn carousel asset: {track_error}")
|
||||
logger.error(f"Failed to track LinkedIn carousel asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
|
||||
return response
|
||||
@@ -410,10 +456,82 @@ async def generate_carousel(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate LinkedIn carousel: {str(e)}"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn carousel: {str(e)}")
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-carousel-pdf",
|
||||
summary="Render Carousel as PDF",
|
||||
description="""
|
||||
Render previously generated LinkedIn carousel content as a PDF document.
|
||||
|
||||
Takes carousel content (slides with title, content, visual_elements) and
|
||||
renders them into visually appealing slide images composed into a PDF
|
||||
ready for LinkedIn upload (1.91:1 aspect ratio, max 300 slides, max 100MB).
|
||||
"""
|
||||
)
|
||||
async def generate_carousel_pdf(
|
||||
request: LinkedInCarouselRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate carousel content and render as PDF."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
user_id = None
|
||||
if current_user:
|
||||
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# First generate carousel content
|
||||
content_result = await linkedin_service.generate_linkedin_carousel(request)
|
||||
|
||||
if not content_result.success or not content_result.data:
|
||||
raise HTTPException(status_code=500, detail=content_result.error or "Carousel generation failed")
|
||||
|
||||
carousel_data = content_result.data.model_dump()
|
||||
|
||||
# Then render to PDF
|
||||
renderer = LinkedInCarouselPDFRenderer()
|
||||
pdf_result = await renderer.render_carousel_to_pdf(
|
||||
carousel_data=carousel_data,
|
||||
color_scheme=request.color_scheme,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not pdf_result.get('success'):
|
||||
raise HTTPException(status_code=500, detail=pdf_result.get('error', 'PDF rendering failed'))
|
||||
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(log_api_request, http_request, db, duration, 200)
|
||||
|
||||
pdf_path = pdf_result.get('pdf_path')
|
||||
if pdf_path:
|
||||
return FileResponse(
|
||||
path=pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename=f"linkedin_carousel_{request.topic[:30].replace(' ', '_')}.pdf"
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
'success': True,
|
||||
'pdf_bytes': pdf_result.get('pdf_bytes'),
|
||||
'metadata': pdf_result.get('metadata'),
|
||||
})
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.error(f"Error generating carousel PDF: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate carousel PDF: {str(e)}"))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-video-script",
|
||||
response_model=LinkedInVideoScriptResponse,
|
||||
@@ -447,14 +565,14 @@ async def generate_video_script(
|
||||
|
||||
# Validate request
|
||||
if not request.topic.strip():
|
||||
raise HTTPException(status_code=422, detail="Topic cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
|
||||
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
|
||||
|
||||
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
|
||||
if video_duration < 15 or video_duration > 300:
|
||||
raise HTTPException(status_code=422, detail="Video length must be between 15 and 300 seconds")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Video length must be between 15 and 300 seconds"))
|
||||
|
||||
# Extract user_id
|
||||
user_id = None
|
||||
@@ -463,18 +581,23 @@ async def generate_video_script(
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# Rate limit check
|
||||
retry_after = check_rate_limit(user_id or 'anonymous')
|
||||
if retry_after:
|
||||
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
|
||||
|
||||
# Generate video script content
|
||||
response = await linkedin_service.generate_linkedin_video_script(request)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Video script generation failed"))
|
||||
|
||||
# Log successful request
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 200
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
try:
|
||||
@@ -514,7 +637,7 @@ async def generate_video_script(
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn video script asset: {track_error}")
|
||||
logger.error(f"Failed to track LinkedIn video script asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
|
||||
return response
|
||||
@@ -532,7 +655,7 @@ async def generate_video_script(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate LinkedIn video script: {str(e)}"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn video script: {str(e)}")
|
||||
)
|
||||
|
||||
|
||||
@@ -572,10 +695,10 @@ async def generate_comment_response(
|
||||
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
|
||||
|
||||
if not original_comment.strip():
|
||||
raise HTTPException(status_code=422, detail="Original comment cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Original comment cannot be empty"))
|
||||
|
||||
if not post_context.strip():
|
||||
raise HTTPException(status_code=422, detail="Post context cannot be empty")
|
||||
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Post context cannot be empty"))
|
||||
|
||||
# Extract user_id
|
||||
user_id = None
|
||||
@@ -584,18 +707,23 @@ async def generate_comment_response(
|
||||
if not user_id:
|
||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
||||
|
||||
# Rate limit check
|
||||
retry_after = check_rate_limit(user_id or 'anonymous')
|
||||
if retry_after:
|
||||
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
|
||||
|
||||
# Generate comment response
|
||||
response = await linkedin_service.generate_linkedin_comment_response(request)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Comment response generation failed"))
|
||||
|
||||
# Log successful request
|
||||
duration = time.time() - start_time
|
||||
background_tasks.add_task(
|
||||
log_api_request, http_request, db, duration, 200
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and hasattr(response, 'response') and response.response:
|
||||
try:
|
||||
@@ -626,7 +754,7 @@ async def generate_comment_response(
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn comment response asset: {track_error}")
|
||||
logger.error(f"Failed to track LinkedIn comment response asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
|
||||
return response
|
||||
@@ -644,7 +772,7 @@ async def generate_comment_response(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate LinkedIn comment response: {str(e)}"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn comment response: {str(e)}")
|
||||
)
|
||||
|
||||
|
||||
@@ -691,6 +819,128 @@ async def get_content_types():
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/edit-content",
|
||||
response_model=LinkedInEditContentResponse,
|
||||
summary="Edit LinkedIn Content with AI",
|
||||
description="""
|
||||
Apply AI-powered edits to LinkedIn content.
|
||||
|
||||
Supported edit types:
|
||||
- professionalize: Rewrite content with professional business language
|
||||
- optimize_engagement: Optimize hook and structure for maximum engagement
|
||||
- add_hashtags: Generate relevant, industry-specific hashtags
|
||||
- adjust_tone: Rewrite content in a different tone (professional, conversational, authoritative, etc.)
|
||||
- expand: Add depth, examples, and insights to content
|
||||
- condense: Shorten content while preserving key messages
|
||||
- add_cta: Generate a contextual call-to-action
|
||||
"""
|
||||
)
|
||||
async def edit_linkedin_content(
|
||||
request: LinkedInEditContentRequest,
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Edit LinkedIn content using AI-powered text generation."""
|
||||
try:
|
||||
# Extract user_id for subscription checking
|
||||
user_id = None
|
||||
if current_user:
|
||||
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
|
||||
|
||||
if not request.content.strip():
|
||||
return LinkedInEditContentResponse(
|
||||
success=False, error="Content cannot be empty", edit_type=request.edit_type
|
||||
)
|
||||
|
||||
# Build the system prompt based on edit type
|
||||
system_prompts = {
|
||||
"professionalize": "You are a professional business writer. Rewrite the following LinkedIn content to be more professional, polished, and industry-appropriate. Maintain the original message but use sophisticated business language, improve sentence structure, and ensure a confident executive presence.",
|
||||
"optimize_engagement": "You are a LinkedIn engagement strategist. Rewrite the following content to maximize engagement. Strengthen the hook in the first 2 lines, add thought-provoking elements, improve readability with shorter sentences, and ensure the content encourages comments and shares.",
|
||||
"add_hashtags": "You are a LinkedIn hashtag strategist. Generate 5 highly relevant, industry-specific hashtags for the following content. Return the original content unchanged, followed by two newlines and the hashtags on a single line.",
|
||||
"adjust_tone": "You are a LinkedIn tone specialist. Rewrite the following content in the specified tone while preserving all key information and the overall message.",
|
||||
"expand": "You are a LinkedIn content strategist. Expand the following content by adding relevant examples, data points, actionable insights, and deeper analysis. Maintain the original structure but add substantial value while keeping it LinkedIn-appropriate (under 3000 characters).",
|
||||
"condense": "You are a LinkedIn editing specialist. Condense the following content to be more concise and impactful. Remove filler words, tighten sentences, and preserve only the strongest points. Keep the core message intact.",
|
||||
"add_cta": "You are a LinkedIn conversion strategist. Add a compelling, contextual call-to-action to the following content. The CTA should feel natural, not salesy, and should encourage meaningful engagement (comments, connections, or discussions)."
|
||||
}
|
||||
|
||||
system_prompt = system_prompts.get(request.edit_type)
|
||||
if not system_prompt:
|
||||
return LinkedInEditContentResponse(
|
||||
success=False, error=f"Unknown edit type: {request.edit_type}", edit_type=request.edit_type
|
||||
)
|
||||
|
||||
# Build the user prompt with context
|
||||
user_prompt = f"Content to edit:\n\n{request.content}\n\n"
|
||||
if request.industry:
|
||||
user_prompt += f"Industry: {request.industry}\n"
|
||||
if request.tone:
|
||||
user_prompt += f"Target tone: {request.tone}\n"
|
||||
if request.target_audience:
|
||||
user_prompt += f"Target audience: {request.target_audience}\n"
|
||||
if request.parameters:
|
||||
user_prompt += f"Additional context: {json.dumps(request.parameters)}\n"
|
||||
|
||||
user_prompt += "\nReturn ONLY the edited content without any explanations, labels, or markdown formatting."
|
||||
|
||||
# Generate edited content using provider-agnostic gateway
|
||||
temperature = {
|
||||
"professionalize": 0.3,
|
||||
"optimize_engagement": 0.7,
|
||||
"add_hashtags": 0.4,
|
||||
"adjust_tone": 0.5,
|
||||
"expand": 0.7,
|
||||
"condense": 0.3,
|
||||
"add_cta": 0.6,
|
||||
}.get(request.edit_type, 0.5)
|
||||
|
||||
max_tokens = {
|
||||
"expand": 2048,
|
||||
"professionalize": 1024,
|
||||
"optimize_engagement": 1024,
|
||||
"adjust_tone": 1024,
|
||||
"condense": 1024,
|
||||
"add_cta": 1024,
|
||||
"add_hashtags": 512,
|
||||
}.get(request.edit_type, 1024)
|
||||
|
||||
edited = llm_text_gen(
|
||||
prompt=user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id,
|
||||
flow_type=f"linkedin_edit_{request.edit_type}",
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
if not edited:
|
||||
return LinkedInEditContentResponse(
|
||||
success=False, error="AI editing returned empty result", edit_type=request.edit_type
|
||||
)
|
||||
|
||||
edited = edited.strip()
|
||||
|
||||
# For add_hashtags, ensure hashtags are separated from content
|
||||
if request.edit_type == "add_hashtags":
|
||||
if not edited.endswith("\n\n"):
|
||||
# Hashtags might be inline; separate them
|
||||
pass
|
||||
|
||||
logger.info(f"LinkedIn content edited successfully via {request.edit_type}")
|
||||
return LinkedInEditContentResponse(
|
||||
success=True,
|
||||
content=edited,
|
||||
edit_type=request.edit_type,
|
||||
provider="llm_text_gen",
|
||||
model="provider-agnostic"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing LinkedIn content: {str(e)}", exc_info=True)
|
||||
return LinkedInEditContentResponse(
|
||||
success=False, error=f"Editing failed: {str(e)}", edit_type=request.edit_type
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/usage-stats",
|
||||
summary="Get Usage Statistics",
|
||||
@@ -699,30 +949,29 @@ async def get_content_types():
|
||||
async def get_usage_stats(db: Session = Depends(get_db)):
|
||||
"""Get usage statistics for LinkedIn content generation."""
|
||||
try:
|
||||
# This would query the database for actual usage stats
|
||||
# For now, returning mock data
|
||||
base = db.query(APIRequest).filter(APIRequest.path.like('/api/linkedin/%'))
|
||||
total = base.count()
|
||||
successful = base.filter(APIRequest.status_code < 400).count()
|
||||
|
||||
avg_dur = base.with_entities(func.avg(APIRequest.duration)).scalar() or 0
|
||||
|
||||
content_types = {
|
||||
"posts": base.filter(APIRequest.path.like('%generate-post')).count(),
|
||||
"articles": base.filter(APIRequest.path.like('%generate-article')).count(),
|
||||
"carousels": base.filter(APIRequest.path.like('%generate-carousel')).count(),
|
||||
"video_scripts": base.filter(APIRequest.path.like('%generate-video-script')).count(),
|
||||
"comment_responses": base.filter(APIRequest.path.like('%generate-comment-response')).count(),
|
||||
}
|
||||
|
||||
return {
|
||||
"total_requests": 1250,
|
||||
"content_types": {
|
||||
"posts": 650,
|
||||
"articles": 320,
|
||||
"carousels": 180,
|
||||
"video_scripts": 70,
|
||||
"comment_responses": 30
|
||||
},
|
||||
"success_rate": 0.96,
|
||||
"average_generation_time": 4.2,
|
||||
"top_industries": [
|
||||
"Technology",
|
||||
"Healthcare",
|
||||
"Finance",
|
||||
"Marketing",
|
||||
"Education"
|
||||
]
|
||||
"total_requests": total,
|
||||
"content_types": content_types,
|
||||
"success_rate": round(successful / max(total, 1), 2),
|
||||
"average_generation_time": round(float(avg_dur), 2),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving usage stats: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve usage statistics"
|
||||
detail=error_response(ERROR_CODES['GENERATION_FAILED'], "Failed to retrieve usage statistics")
|
||||
)
|
||||
@@ -17,13 +17,13 @@ from .content_generator_prompts import (
|
||||
VideoScriptGenerator
|
||||
)
|
||||
|
||||
# Import new image generation services
|
||||
# Import image generation services
|
||||
from .image_generation import (
|
||||
LinkedInImageGenerator,
|
||||
LinkedInImageEditor,
|
||||
LinkedInImageStorage
|
||||
)
|
||||
from .image_prompts import LinkedInPromptGenerator
|
||||
from .carousel import LinkedInCarouselPDFRenderer
|
||||
|
||||
__all__ = [
|
||||
# Content Generation
|
||||
@@ -42,9 +42,10 @@ __all__ = [
|
||||
|
||||
# Image Generation Services
|
||||
'LinkedInImageGenerator',
|
||||
'LinkedInImageEditor',
|
||||
'LinkedInImageStorage',
|
||||
'LinkedInPromptGenerator'
|
||||
'LinkedInPromptGenerator',
|
||||
# Carousel Rendering
|
||||
'LinkedInCarouselPDFRenderer',
|
||||
]
|
||||
|
||||
# Version information
|
||||
|
||||
3
backend/services/linkedin/carousel/__init__.py
Normal file
3
backend/services/linkedin/carousel/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .carousel_renderer import LinkedInCarouselPDFRenderer
|
||||
|
||||
__all__ = ['LinkedInCarouselPDFRenderer']
|
||||
336
backend/services/linkedin/carousel/carousel_renderer.py
Normal file
336
backend/services/linkedin/carousel/carousel_renderer.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
LinkedIn Carousel PDF Renderer
|
||||
|
||||
Renders text-based carousel slides into visually appealing PNG images
|
||||
and composes them into a LinkedIn-compatible PDF document (1.91:1 ratio).
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
from reportlab.lib.pagesizes import landscape
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import SimpleDocTemplate, Image as RLImage, PageBreak
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinkedInCarouselPDFRenderer:
|
||||
|
||||
COLOR_SCHEMES = {
|
||||
'professional': {
|
||||
'background_start': (25, 55, 109),
|
||||
'background_end': (41, 128, 185),
|
||||
'title_color': (255, 255, 255),
|
||||
'content_color': (236, 240, 241),
|
||||
'accent_color': (52, 152, 219),
|
||||
},
|
||||
'creative': {
|
||||
'background_start': (142, 68, 173),
|
||||
'background_end': (231, 76, 60),
|
||||
'title_color': (255, 255, 255),
|
||||
'content_color': (245, 245, 245),
|
||||
'accent_color': (241, 196, 15),
|
||||
},
|
||||
'industry': {
|
||||
'background_start': (39, 174, 96),
|
||||
'background_end': (44, 62, 80),
|
||||
'title_color': (255, 255, 255),
|
||||
'content_color': (236, 240, 241),
|
||||
'accent_color': (46, 204, 113),
|
||||
},
|
||||
'dark': {
|
||||
'background_start': (20, 20, 30),
|
||||
'background_end': (60, 60, 80),
|
||||
'title_color': (255, 255, 255),
|
||||
'content_color': (200, 200, 210),
|
||||
'accent_color': (100, 200, 255),
|
||||
},
|
||||
'minimal': {
|
||||
'background_start': (245, 245, 250),
|
||||
'background_end': (255, 255, 255),
|
||||
'title_color': (44, 62, 80),
|
||||
'content_color': (80, 80, 90),
|
||||
'accent_color': (52, 152, 219),
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, output_dir: str = None):
|
||||
self.slide_width = 1200
|
||||
self.slide_height = 627
|
||||
self.slide_aspect_ratio = "1.91:1"
|
||||
self.max_file_size_bytes = 100 * 1024 * 1024
|
||||
self.max_slides = 300
|
||||
self.output_dir = output_dir or "data/media/linkedin_carousels"
|
||||
|
||||
async def render_carousel_to_pdf(
|
||||
self,
|
||||
carousel_data: Dict[str, Any],
|
||||
color_scheme: str = 'professional',
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_time = datetime.now()
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
slides = carousel_data.get('slides', [])
|
||||
if not slides:
|
||||
return {'success': False, 'error': 'No slides to render'}
|
||||
|
||||
title = carousel_data.get('title', 'LinkedIn Carousel')
|
||||
cover_slide = carousel_data.get('cover_slide')
|
||||
cta_slide = carousel_data.get('cta_slide')
|
||||
total_slides = len(slides) + (1 if cover_slide else 0) + (1 if cta_slide else 0)
|
||||
|
||||
if total_slides > self.max_slides:
|
||||
error = f'Too many slides: {total_slides} exceeds max {self.max_slides}'
|
||||
return {'success': False, 'error': error}
|
||||
|
||||
session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
image_paths = []
|
||||
|
||||
if cover_slide:
|
||||
path = self._render_slide(
|
||||
slide=cover_slide, slide_number=0, session_id=session_id,
|
||||
color_scheme=color_scheme, is_cover=True, carousel_title=title,
|
||||
)
|
||||
if path:
|
||||
image_paths.append(path)
|
||||
|
||||
for i, slide in enumerate(slides):
|
||||
path = self._render_slide(
|
||||
slide=slide, slide_number=i + 1, session_id=session_id,
|
||||
color_scheme=color_scheme, is_cover=False,
|
||||
)
|
||||
if path:
|
||||
image_paths.append(path)
|
||||
|
||||
if cta_slide:
|
||||
path = self._render_slide(
|
||||
slide=cta_slide, slide_number=len(slides) + 1, session_id=session_id,
|
||||
color_scheme=color_scheme, is_cta=True,
|
||||
)
|
||||
if path:
|
||||
image_paths.append(path)
|
||||
|
||||
if not image_paths:
|
||||
return {'success': False, 'error': 'No slide images generated'}
|
||||
|
||||
pdf_filename = f"linkedin_carousel_{session_id}.pdf"
|
||||
pdf_path = os.path.join(self.output_dir, pdf_filename)
|
||||
pdf_bytes = self._compose_pdf(image_paths, pdf_path)
|
||||
|
||||
file_size = len(pdf_bytes)
|
||||
if file_size > self.max_file_size_bytes:
|
||||
logger.warning("PDF size %.2f MB exceeds max %.2f MB",
|
||||
file_size / (1024 * 1024), self.max_file_size_bytes / (1024 * 1024))
|
||||
|
||||
generation_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'pdf_bytes': pdf_bytes,
|
||||
'pdf_path': pdf_path,
|
||||
'metadata': {
|
||||
'slide_count': len(image_paths),
|
||||
'generation_time': generation_time,
|
||||
'file_size': file_size,
|
||||
'file_size_mb': round(file_size / (1024 * 1024), 2),
|
||||
'dimensions': f'{self.slide_width}x{self.slide_height}',
|
||||
'aspect_ratio': self.slide_aspect_ratio,
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error rendering carousel PDF: %s", str(e))
|
||||
return {'success': False, 'error': f'Carousel PDF rendering failed: {str(e)}'}
|
||||
|
||||
def _render_slide(
|
||||
self,
|
||||
slide: Dict[str, Any],
|
||||
slide_number: int,
|
||||
session_id: str,
|
||||
color_scheme: str = 'professional',
|
||||
is_cover: bool = False,
|
||||
is_cta: bool = False,
|
||||
carousel_title: str = '',
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
colors = self.COLOR_SCHEMES.get(color_scheme, self.COLOR_SCHEMES['professional'])
|
||||
|
||||
img = Image.new('RGB', (self.slide_width, self.slide_height))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
self._draw_gradient(draw, colors)
|
||||
|
||||
draw.rectangle([0, self.slide_height - 6, self.slide_width, self.slide_height], fill=colors['accent_color'])
|
||||
|
||||
if is_cover:
|
||||
self._draw_centered_text(draw, carousel_title or slide.get('title', ''),
|
||||
(self.slide_width // 2, 180), colors['title_color'],
|
||||
font_size=42, max_width=self.slide_width - 160)
|
||||
|
||||
subtitle = slide.get('content', '')
|
||||
if subtitle:
|
||||
self._draw_centered_text(draw, subtitle,
|
||||
(self.slide_width // 2, 320), colors['content_color'],
|
||||
font_size=24, max_width=self.slide_width - 200, max_lines=3)
|
||||
|
||||
self._draw_centered_text(draw, "Swipe to explore →",
|
||||
(self.slide_width // 2, 480), colors['accent_color'],
|
||||
font_size=18)
|
||||
elif is_cta:
|
||||
self._draw_text(draw, slide.get('title', ''), (60, 160), colors['title_color'],
|
||||
font_size=36, max_width=self.slide_width - 120, max_lines=2)
|
||||
|
||||
content = slide.get('content', '')
|
||||
if content:
|
||||
self._draw_text(draw, content, (60, 260), colors['content_color'],
|
||||
font_size=22, max_width=self.slide_width - 120, max_lines=6)
|
||||
|
||||
btn_x, btn_y = self.slide_width // 2 - 200, 440
|
||||
draw.rounded_rectangle([btn_x, btn_y, btn_x + 400, btn_y + 55], radius=27, fill=colors['accent_color'])
|
||||
self._draw_centered_text(draw, "Share Your Thoughts →",
|
||||
(self.slide_width // 2, btn_y + 27), (255, 255, 255), font_size=22)
|
||||
else:
|
||||
self._draw_text(draw, str(slide_number),
|
||||
(self.slide_width - 50, 20), colors['accent_color'], font_size=16)
|
||||
|
||||
title = slide.get('title', '')
|
||||
if title:
|
||||
self._draw_text(draw, title, (60, 50), colors['title_color'],
|
||||
font_size=30, max_width=self.slide_width - 120, max_lines=2)
|
||||
|
||||
content = slide.get('content', '')
|
||||
if content:
|
||||
self._draw_text(draw, content, (60, 145), colors['content_color'],
|
||||
font_size=20, max_width=self.slide_width - 120, max_lines=10)
|
||||
|
||||
visual_elements = slide.get('visual_elements', [])
|
||||
if visual_elements:
|
||||
self._draw_visual_elements(draw, visual_elements, colors)
|
||||
|
||||
filename = f"slide_{session_id}_{slide_number:03d}.png"
|
||||
filepath = os.path.join(self.output_dir, filename)
|
||||
img.save(filepath, 'PNG', optimize=True)
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error rendering slide %d: %s", slide_number, str(e))
|
||||
return None
|
||||
|
||||
def _draw_gradient(self, draw: ImageDraw.Draw, colors: Dict):
|
||||
sr, sg, sb = colors['background_start']
|
||||
er, eg, eb = colors['background_end']
|
||||
for y in range(self.slide_height):
|
||||
t = y / self.slide_height
|
||||
draw.line([(0, y), (self.slide_width, y)],
|
||||
fill=(int(sr + (er - sr) * t), int(sg + (eg - sg) * t), int(sb + (eb - sb) * t)))
|
||||
|
||||
def _draw_text(self, draw: ImageDraw.Draw, text: str, position: tuple, color: tuple,
|
||||
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
|
||||
font = self._get_font(font_size, bold)
|
||||
x, y = position
|
||||
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bb = draw.textbbox((0, 0), test_line, font=font)
|
||||
tw = bb[2] - bb[0]
|
||||
if max_width and tw > max_width and current_line:
|
||||
lines.append(current_line)
|
||||
if max_lines and len(lines) >= max_lines:
|
||||
lines[-1] = lines[-1][:-3] + "..."
|
||||
break
|
||||
current_line = word
|
||||
else:
|
||||
current_line = test_line
|
||||
if current_line and (not max_lines or len(lines) < max_lines):
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = int(font_size * 1.4)
|
||||
for i, line in enumerate(lines):
|
||||
draw.text((x, y + i * line_height), line, fill=color, font=font)
|
||||
|
||||
def _draw_centered_text(self, draw: ImageDraw.Draw, text: str, center: tuple, color: tuple,
|
||||
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
|
||||
font = self._get_font(font_size, bold)
|
||||
cx, cy = center
|
||||
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bb = draw.textbbox((0, 0), test_line, font=font)
|
||||
tw = bb[2] - bb[0]
|
||||
if max_width and tw > max_width and current_line:
|
||||
lines.append(current_line)
|
||||
if max_lines and len(lines) >= max_lines:
|
||||
lines[-1] = lines[-1][:-3] + "..."
|
||||
break
|
||||
current_line = word
|
||||
else:
|
||||
current_line = test_line
|
||||
if current_line and (not max_lines or len(lines) < max_lines):
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = int(font_size * 1.4)
|
||||
total_height = len(lines) * line_height
|
||||
start_y = cy - total_height // 2
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
bb = draw.textbbox((0, 0), line, font=font)
|
||||
tw = bb[2] - bb[0]
|
||||
x = cx - tw // 2
|
||||
draw.text((x, start_y + i * line_height), line, fill=color, font=font)
|
||||
|
||||
def _draw_visual_elements(self, draw: ImageDraw.Draw, elements: List[str], colors: Dict):
|
||||
y_start = self.slide_height - 60
|
||||
x_start = 60
|
||||
for i, element in enumerate(elements[:4]):
|
||||
cx = x_start + i * 280
|
||||
draw.ellipse([cx, y_start, cx + 12, y_start + 12], fill=colors['accent_color'])
|
||||
font = self._get_font(12, False)
|
||||
draw.text((cx + 20, y_start - 2), element[:25], fill=colors['content_color'], font=font)
|
||||
|
||||
def _get_font(self, size: int, bold: bool = False):
|
||||
try:
|
||||
return ImageFont.truetype("arialbd.ttf" if bold else "arial.ttf", size)
|
||||
except (IOError, OSError):
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", size)
|
||||
except (IOError, OSError):
|
||||
return ImageFont.load_default()
|
||||
|
||||
def _compose_pdf(self, image_paths: List[str], output_path: str) -> bytes:
|
||||
pw = self.slide_width
|
||||
ph = self.slide_height
|
||||
# Leave 1pt margin to avoid ReportLab frame size issues
|
||||
m = 1
|
||||
iw = pw - 2 * m
|
||||
ih = ph - 2 * m
|
||||
|
||||
from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate
|
||||
from reportlab.lib.pagesizes import landscape
|
||||
|
||||
frame = Frame(m, m, iw, ih, id="slide_frame",
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0)
|
||||
template = PageTemplate(id="slide", frames=[frame], pagesize=(pw, ph))
|
||||
doc = BaseDocTemplate(output_path, pagesize=(pw, ph))
|
||||
doc.addPageTemplates([template])
|
||||
|
||||
story = []
|
||||
for i, img_path in enumerate(image_paths):
|
||||
story.append(RLImage(img_path, width=iw, height=ih))
|
||||
if i < len(image_paths) - 1:
|
||||
story.append(PageBreak())
|
||||
|
||||
doc.build(story)
|
||||
|
||||
with open(output_path, 'rb') as f:
|
||||
return f.read()
|
||||
@@ -2,6 +2,7 @@
|
||||
Content Generator for LinkedIn Content Generation
|
||||
|
||||
Handles the main content generation logic for posts and articles.
|
||||
Uses llm_text_gen for provider-agnostic LLM access (respects GPT_PROVIDER).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
@@ -21,6 +22,7 @@ from services.linkedin.content_generator_prompts import (
|
||||
CarouselGenerator,
|
||||
VideoScriptGenerator
|
||||
)
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.persona_analysis_service import PersonaAnalysisService
|
||||
import time
|
||||
|
||||
@@ -28,11 +30,9 @@ import time
|
||||
class ContentGenerator:
|
||||
"""Handles content generation for all LinkedIn content types."""
|
||||
|
||||
def __init__(self, citation_manager=None, quality_analyzer=None, gemini_grounded=None, fallback_provider=None):
|
||||
def __init__(self, citation_manager=None, quality_analyzer=None):
|
||||
self.citation_manager = citation_manager
|
||||
self.quality_analyzer = quality_analyzer
|
||||
self.gemini_grounded = gemini_grounded
|
||||
self.fallback_provider = fallback_provider
|
||||
|
||||
# Persona caching
|
||||
self._persona_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -105,22 +105,24 @@ class ContentGenerator:
|
||||
del self._cache_timestamps[key]
|
||||
logger.info(f"Cleared persona cache for user {user_id}")
|
||||
|
||||
def _transform_gemini_sources(self, gemini_sources):
|
||||
"""Transform Gemini sources to ResearchSource format."""
|
||||
transformed_sources = []
|
||||
for source in gemini_sources:
|
||||
transformed_source = ResearchSource(
|
||||
title=source.get('title', 'Unknown Source'),
|
||||
url=source.get('url', ''),
|
||||
content=f"Source from {source.get('title', 'Unknown')}",
|
||||
relevance_score=0.8, # Default relevance score
|
||||
credibility_score=0.7, # Default credibility score
|
||||
domain_authority=0.6, # Default domain authority
|
||||
source_type=source.get('type', 'web'),
|
||||
publication_date=datetime.now().strftime('%Y-%m-%d')
|
||||
)
|
||||
transformed_sources.append(transformed_source)
|
||||
return transformed_sources
|
||||
def _build_research_context(self, research_sources: List) -> str:
|
||||
"""Build research context string from research sources for prompt injection."""
|
||||
if not research_sources:
|
||||
return ""
|
||||
|
||||
context_parts = ["\n\nRESEARCH CONTEXT (use this information to ground your content with facts and data):"]
|
||||
for i, source in enumerate(research_sources[:5], 1): # Limit to top 5 sources
|
||||
title = getattr(source, 'title', f'Source {i}')
|
||||
url = getattr(source, 'url', '')
|
||||
content = getattr(source, 'content', '')
|
||||
context_parts.append(f"\n{i}. {title}")
|
||||
if url:
|
||||
context_parts.append(f" URL: {url}")
|
||||
if content:
|
||||
context_parts.append(f" Key insight: {content[:300]}")
|
||||
|
||||
context_parts.append("\nInstructions: Use the research above to include specific data points, statistics, and factual claims in your content. Cite sources where appropriate.")
|
||||
return "\n".join(context_parts)
|
||||
|
||||
async def generate_post(
|
||||
self,
|
||||
@@ -155,21 +157,12 @@ class ContentGenerator:
|
||||
logger.info(f" - First research source: {research_sources[0] if research_sources else 'None'}")
|
||||
logger.info(f" - Research sources types: {[type(s) for s in research_sources[:3]]}")
|
||||
|
||||
# Step 3: Add citations if requested - POST METHOD
|
||||
# Step 3: Add citations if requested
|
||||
citations = []
|
||||
source_list = None
|
||||
final_research_sources = research_sources # Default to passed research_sources
|
||||
final_research_sources = research_sources
|
||||
|
||||
# Use sources and citations from content_result if available (from Gemini grounding)
|
||||
if content_result.get('citations') and content_result.get('sources'):
|
||||
logger.info(f"Using citations and sources from Gemini grounding: {len(content_result['citations'])} citations, {len(content_result['sources'])} sources")
|
||||
citations = content_result['citations']
|
||||
# Transform Gemini sources to ResearchSource format
|
||||
gemini_sources = self._transform_gemini_sources(content_result['sources'])
|
||||
source_list = self.citation_manager.generate_source_list(gemini_sources) if self.citation_manager else None
|
||||
# Use transformed sources for the response
|
||||
final_research_sources = gemini_sources
|
||||
elif request.include_citations and research_sources and self.citation_manager:
|
||||
if request.include_citations and research_sources and self.citation_manager:
|
||||
try:
|
||||
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
|
||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||
@@ -224,7 +217,7 @@ class ContentGenerator:
|
||||
data=post_content,
|
||||
research_sources=final_research_sources, # Use final_research_sources
|
||||
generation_metadata={
|
||||
'model_used': 'gemini-2.0-flash-001',
|
||||
'model_used': 'llm_text_gen',
|
||||
'generation_time': generation_time,
|
||||
'research_time': research_time,
|
||||
'grounding_enabled': grounding_enabled
|
||||
@@ -251,21 +244,12 @@ class ContentGenerator:
|
||||
try:
|
||||
start_time = datetime.now()
|
||||
|
||||
# Step 3: Add citations if requested - ARTICLE METHOD
|
||||
# Step 3: Add citations if requested
|
||||
citations = []
|
||||
source_list = None
|
||||
final_research_sources = research_sources # Default to passed research_sources
|
||||
final_research_sources = research_sources
|
||||
|
||||
# Use sources and citations from content_result if available (from Gemini grounding)
|
||||
if content_result.get('citations') and content_result.get('sources'):
|
||||
logger.info(f"Using citations and sources from Gemini grounding: {len(content_result['citations'])} citations, {len(content_result['sources'])} sources")
|
||||
citations = content_result['citations']
|
||||
# Transform Gemini sources to ResearchSource format
|
||||
gemini_sources = self._transform_gemini_sources(content_result['sources'])
|
||||
source_list = self.citation_manager.generate_source_list(gemini_sources) if self.citation_manager else None
|
||||
# Use transformed sources for the response
|
||||
final_research_sources = gemini_sources
|
||||
elif request.include_citations and research_sources and self.citation_manager:
|
||||
if request.include_citations and research_sources and self.citation_manager:
|
||||
try:
|
||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||
source_list = self.citation_manager.generate_source_list(research_sources)
|
||||
@@ -317,7 +301,7 @@ class ContentGenerator:
|
||||
data=article_content,
|
||||
research_sources=final_research_sources, # Use final_research_sources
|
||||
generation_metadata={
|
||||
'model_used': 'gemini-2.0-flash-001',
|
||||
'model_used': 'llm_text_gen',
|
||||
'generation_time': generation_time,
|
||||
'research_time': research_time,
|
||||
'grounding_enabled': grounding_enabled
|
||||
@@ -386,7 +370,7 @@ class ContentGenerator:
|
||||
'alternative_responses': content_result.get('alternative_responses', []),
|
||||
'tone_analysis': content_result.get('tone_analysis'),
|
||||
'generation_metadata': {
|
||||
'model_used': 'gemini-2.0-flash-001',
|
||||
'model_used': 'llm_text_gen',
|
||||
'generation_time': generation_time,
|
||||
'research_time': research_time,
|
||||
'grounding_enabled': grounding_enabled
|
||||
@@ -402,19 +386,14 @@ class ContentGenerator:
|
||||
}
|
||||
|
||||
# Grounded content generation methods
|
||||
async def generate_grounded_post_content(self, request, research_sources: List) -> Dict[str, Any]:
|
||||
"""Generate grounded post content using the enhanced Gemini provider with native grounding."""
|
||||
async def generate_grounded_post_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate post content using provider-agnostic llm_text_gen."""
|
||||
try:
|
||||
if not self.gemini_grounded:
|
||||
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 persona if available (DB vs session override)
|
||||
user_id = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
|
||||
# Build the prompt using persona if available
|
||||
uid = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||
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', {})
|
||||
@@ -431,61 +410,40 @@ class ContentGenerator:
|
||||
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(
|
||||
# Inject research context into prompt
|
||||
research_context = self._build_research_context(research_sources)
|
||||
if research_context:
|
||||
prompt += research_context
|
||||
|
||||
# Generate content using provider-agnostic gateway
|
||||
raw_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_post",
|
||||
temperature=0.7,
|
||||
max_tokens=request.max_length
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_post",
|
||||
max_tokens=request.max_length,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return result
|
||||
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating grounded post content: {str(e)}")
|
||||
logger.info("Attempting fallback to standard content generation...")
|
||||
|
||||
# Fallback to standard content generation without grounding
|
||||
try:
|
||||
if not self.fallback_provider:
|
||||
raise Exception("No fallback provider available")
|
||||
|
||||
# Build a simpler prompt for fallback generation
|
||||
prompt = PostPromptBuilder.build_post_prompt(request)
|
||||
|
||||
# Generate content using fallback provider (it's a dict with functions)
|
||||
if 'generate_text' in self.fallback_provider:
|
||||
result = await self.fallback_provider['generate_text'](
|
||||
prompt=prompt,
|
||||
temperature=0.7,
|
||||
max_tokens=request.max_length
|
||||
)
|
||||
else:
|
||||
raise Exception("Fallback provider doesn't have generate_text method")
|
||||
|
||||
# Return result in the expected format
|
||||
return {
|
||||
'content': result.get('content', '') if isinstance(result, dict) else str(result),
|
||||
'content': content_text,
|
||||
'sources': [],
|
||||
'citations': [],
|
||||
'grounding_enabled': False,
|
||||
'fallback_used': True
|
||||
'grounding_enabled': bool(research_sources),
|
||||
'fallback_used': False
|
||||
}
|
||||
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Fallback generation also failed: {str(fallback_error)}")
|
||||
raise Exception(f"Failed to generate content: {str(e)}. Fallback also failed: {str(fallback_error)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating post content: {str(e)}")
|
||||
raise Exception(f"Failed to generate LinkedIn post: {str(e)}")
|
||||
|
||||
async def generate_grounded_article_content(self, request, research_sources: List) -> Dict[str, Any]:
|
||||
"""Generate grounded article content using the enhanced Gemini provider with native grounding."""
|
||||
async def generate_grounded_article_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate article content using provider-agnostic llm_text_gen."""
|
||||
try:
|
||||
if not self.gemini_grounded:
|
||||
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 persona if available (DB vs session override)
|
||||
user_id = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
|
||||
# Build the prompt using persona if available
|
||||
uid = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||
if getattr(request, 'persona_override', None):
|
||||
try:
|
||||
override = request.persona_override
|
||||
@@ -504,88 +462,129 @@ class ContentGenerator:
|
||||
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(
|
||||
# Inject research context into prompt
|
||||
research_context = self._build_research_context(research_sources)
|
||||
if research_context:
|
||||
prompt += research_context
|
||||
|
||||
# Generate content using provider-agnostic gateway
|
||||
raw_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_article",
|
||||
temperature=0.7,
|
||||
max_tokens=request.word_count * 10 # Approximate character count
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_article",
|
||||
max_tokens=request.word_count * 10,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return result
|
||||
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
|
||||
|
||||
return {
|
||||
'content': content_text,
|
||||
'sources': [],
|
||||
'citations': [],
|
||||
'grounding_enabled': bool(research_sources),
|
||||
'fallback_used': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating grounded article content: {str(e)}")
|
||||
raise Exception(f"Failed to generate grounded article content: {str(e)}")
|
||||
logger.error(f"Error generating article content: {str(e)}")
|
||||
raise Exception(f"Failed to generate LinkedIn article: {str(e)}")
|
||||
|
||||
async def generate_grounded_carousel_content(self, request, research_sources: List) -> Dict[str, Any]:
|
||||
"""Generate grounded carousel content using the enhanced Gemini provider with native grounding."""
|
||||
async def generate_grounded_carousel_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate carousel content using provider-agnostic llm_text_gen."""
|
||||
try:
|
||||
if not self.gemini_grounded:
|
||||
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 = CarouselPromptBuilder.build_carousel_prompt(request)
|
||||
|
||||
# Generate grounded content using native Google Search grounding
|
||||
result = await self.gemini_grounded.generate_grounded_content(
|
||||
# Inject research context into prompt
|
||||
research_context = self._build_research_context(research_sources)
|
||||
if research_context:
|
||||
prompt += research_context
|
||||
|
||||
# Generate content using provider-agnostic gateway
|
||||
raw_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_carousel",
|
||||
temperature=0.7,
|
||||
max_tokens=2000
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_carousel",
|
||||
max_tokens=2000,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return result
|
||||
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
|
||||
|
||||
return {
|
||||
'content': content_text,
|
||||
'sources': [],
|
||||
'citations': [],
|
||||
'grounding_enabled': bool(research_sources),
|
||||
'fallback_used': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating grounded carousel content: {str(e)}")
|
||||
raise Exception(f"Failed to generate grounded carousel content: {str(e)}")
|
||||
logger.error(f"Error generating carousel content: {str(e)}")
|
||||
raise Exception(f"Failed to generate LinkedIn carousel: {str(e)}")
|
||||
|
||||
async def generate_grounded_video_script_content(self, request, research_sources: List) -> Dict[str, Any]:
|
||||
"""Generate grounded video script content using the enhanced Gemini provider with native grounding."""
|
||||
async def generate_grounded_video_script_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate video script content using provider-agnostic llm_text_gen."""
|
||||
try:
|
||||
if not self.gemini_grounded:
|
||||
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 = VideoScriptPromptBuilder.build_video_script_prompt(request)
|
||||
|
||||
# Generate grounded content using native Google Search grounding
|
||||
result = await self.gemini_grounded.generate_grounded_content(
|
||||
# Inject research context into prompt
|
||||
research_context = self._build_research_context(research_sources)
|
||||
if research_context:
|
||||
prompt += research_context
|
||||
|
||||
# Generate content using provider-agnostic gateway
|
||||
raw_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_video_script",
|
||||
temperature=0.7,
|
||||
max_tokens=1500
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_video_script",
|
||||
max_tokens=1500,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return result
|
||||
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
|
||||
|
||||
return {
|
||||
'content': content_text,
|
||||
'sources': [],
|
||||
'citations': [],
|
||||
'grounding_enabled': bool(research_sources),
|
||||
'fallback_used': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating grounded video script content: {str(e)}")
|
||||
raise Exception(f"Failed to generate grounded video script content: {str(e)}")
|
||||
logger.error(f"Error generating video script content: {str(e)}")
|
||||
raise Exception(f"Failed to generate LinkedIn video script: {str(e)}")
|
||||
|
||||
async def generate_grounded_comment_response(self, request, research_sources: List) -> Dict[str, Any]:
|
||||
"""Generate grounded comment response using the enhanced Gemini provider with native grounding."""
|
||||
async def generate_grounded_comment_response(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate comment response using provider-agnostic llm_text_gen."""
|
||||
try:
|
||||
if not self.gemini_grounded:
|
||||
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 = CommentResponsePromptBuilder.build_comment_response_prompt(request)
|
||||
|
||||
# Generate grounded content using native Google Search grounding
|
||||
result = await self.gemini_grounded.generate_grounded_content(
|
||||
# Inject research context into prompt
|
||||
research_context = self._build_research_context(research_sources)
|
||||
if research_context:
|
||||
prompt += research_context
|
||||
|
||||
# Generate content using provider-agnostic gateway
|
||||
raw_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_comment_response",
|
||||
temperature=0.7,
|
||||
max_tokens=2000
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_comment_response",
|
||||
max_tokens=2000,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return result
|
||||
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
|
||||
|
||||
return {
|
||||
'content': content_text,
|
||||
'sources': [],
|
||||
'citations': [],
|
||||
'grounding_enabled': bool(research_sources),
|
||||
'fallback_used': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating grounded comment response: {str(e)}")
|
||||
raise Exception(f"Failed to generate grounded comment response: {str(e)}")
|
||||
logger.error(f"Error generating comment response: {str(e)}")
|
||||
raise Exception(f"Failed to generate LinkedIn comment response: {str(e)}")
|
||||
|
||||
@@ -96,7 +96,7 @@ class CarouselGenerator:
|
||||
'data': carousel_content,
|
||||
'research_sources': research_sources,
|
||||
'generation_metadata': {
|
||||
'model_used': 'gemini-2.0-flash-001',
|
||||
'model_used': 'llm_text_gen',
|
||||
'generation_time': generation_time,
|
||||
'research_time': research_time,
|
||||
'grounding_enabled': grounding_enabled
|
||||
|
||||
@@ -81,7 +81,7 @@ class VideoScriptGenerator:
|
||||
'data': video_script,
|
||||
'research_sources': research_sources,
|
||||
'generation_metadata': {
|
||||
'model_used': 'gemini-2.0-flash-001',
|
||||
'model_used': 'llm_text_gen',
|
||||
'generation_time': generation_time,
|
||||
'research_time': research_time,
|
||||
'grounding_enabled': grounding_enabled
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
LinkedIn Image Generation Package
|
||||
|
||||
This package provides AI-powered image generation capabilities for LinkedIn content
|
||||
using Google's Gemini API. It includes image generation, editing, storage, and
|
||||
management services optimized for professional business use.
|
||||
using the common llm_providers infrastructure. It includes image generation, storage,
|
||||
and management services optimized for professional business use.
|
||||
"""
|
||||
|
||||
from .linkedin_image_generator import LinkedInImageGenerator
|
||||
from .linkedin_image_editor import LinkedInImageEditor
|
||||
from .linkedin_image_storage import LinkedInImageStorage
|
||||
|
||||
__all__ = [
|
||||
'LinkedInImageGenerator',
|
||||
'LinkedInImageEditor',
|
||||
'LinkedInImageStorage'
|
||||
]
|
||||
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"""
|
||||
LinkedIn Image Editor Service
|
||||
|
||||
This service handles image editing capabilities for LinkedIn content using Gemini's
|
||||
conversational editing features. It provides professional image refinement and
|
||||
optimization specifically for LinkedIn use cases.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
from io import BytesIO
|
||||
from loguru import logger
|
||||
|
||||
# Import existing infrastructure
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
|
||||
|
||||
class LinkedInImageEditor:
|
||||
"""
|
||||
Handles LinkedIn image editing and refinement using Gemini's capabilities.
|
||||
|
||||
This service provides both AI-powered editing through Gemini and traditional
|
||||
image processing for LinkedIn-specific optimizations.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key_manager: Optional[APIKeyManager] = None):
|
||||
"""
|
||||
Initialize the LinkedIn Image Editor.
|
||||
|
||||
Args:
|
||||
api_key_manager: API key manager for Gemini authentication
|
||||
"""
|
||||
self.api_key_manager = api_key_manager or APIKeyManager()
|
||||
self.model = "gemini-2.5-flash-image-preview"
|
||||
|
||||
# LinkedIn-specific editing parameters
|
||||
self.enhancement_factors = {
|
||||
'brightness': 1.1, # Slightly brighter for mobile viewing
|
||||
'contrast': 1.05, # Subtle contrast enhancement
|
||||
'sharpness': 1.2, # Enhanced sharpness for clarity
|
||||
'saturation': 1.05 # Slight saturation boost
|
||||
}
|
||||
|
||||
logger.info("LinkedIn Image Editor initialized")
|
||||
|
||||
async def edit_image_conversationally(
|
||||
self,
|
||||
base_image: bytes,
|
||||
edit_prompt: str,
|
||||
content_context: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Edit image using Gemini's conversational editing capabilities.
|
||||
|
||||
Args:
|
||||
base_image: Base image data in bytes
|
||||
edit_prompt: Natural language description of desired edits
|
||||
content_context: LinkedIn content context for optimization
|
||||
|
||||
Returns:
|
||||
Dict containing edited image result and metadata
|
||||
"""
|
||||
try:
|
||||
start_time = datetime.now()
|
||||
logger.info(f"Starting conversational image editing: {edit_prompt[:100]}...")
|
||||
|
||||
# Enhance edit prompt for LinkedIn optimization
|
||||
enhanced_prompt = self._enhance_edit_prompt_for_linkedin(
|
||||
edit_prompt, content_context
|
||||
)
|
||||
|
||||
# TODO: Implement Gemini conversational editing when available
|
||||
# For now, we'll use traditional image processing based on prompt analysis
|
||||
edited_image = await self._apply_traditional_editing(
|
||||
base_image, edit_prompt, content_context
|
||||
)
|
||||
|
||||
if not edited_image.get('success'):
|
||||
return edited_image
|
||||
|
||||
generation_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': edited_image['image_data'],
|
||||
'metadata': {
|
||||
'edit_prompt': edit_prompt,
|
||||
'enhanced_prompt': enhanced_prompt,
|
||||
'editing_method': 'traditional_processing',
|
||||
'editing_time': generation_time,
|
||||
'content_context': content_context,
|
||||
'model_used': self.model
|
||||
},
|
||||
'linkedin_optimization': {
|
||||
'mobile_optimized': True,
|
||||
'professional_aesthetic': True,
|
||||
'brand_compliant': True,
|
||||
'engagement_optimized': True
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in conversational image editing: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Conversational editing failed: {str(e)}",
|
||||
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
|
||||
}
|
||||
|
||||
async def apply_style_transfer(
|
||||
self,
|
||||
base_image: bytes,
|
||||
style_reference: bytes,
|
||||
content_context: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply style transfer from reference image to base image.
|
||||
|
||||
Args:
|
||||
base_image: Base image data in bytes
|
||||
style_reference: Reference image for style transfer
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
Dict containing style-transferred image result
|
||||
"""
|
||||
try:
|
||||
start_time = datetime.now()
|
||||
logger.info("Starting style transfer for LinkedIn image")
|
||||
|
||||
# TODO: Implement Gemini style transfer when available
|
||||
# For now, return placeholder implementation
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Style transfer not yet implemented - coming in next Gemini API update',
|
||||
'generation_time': (datetime.now() - start_time).total_seconds()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in style transfer: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Style transfer failed: {str(e)}",
|
||||
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
|
||||
}
|
||||
|
||||
async def enhance_image_quality(
|
||||
self,
|
||||
image_data: bytes,
|
||||
enhancement_type: str = "linkedin_optimized",
|
||||
content_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enhance image quality using traditional image processing.
|
||||
|
||||
Args:
|
||||
image_data: Image data in bytes
|
||||
enhancement_type: Type of enhancement to apply
|
||||
content_context: LinkedIn content context for optimization
|
||||
|
||||
Returns:
|
||||
Dict containing enhanced image result
|
||||
"""
|
||||
try:
|
||||
start_time = datetime.now()
|
||||
logger.info(f"Starting image quality enhancement: {enhancement_type}")
|
||||
|
||||
# Open image for processing
|
||||
image = Image.open(BytesIO(image_data))
|
||||
original_size = image.size
|
||||
|
||||
# Apply LinkedIn-specific enhancements
|
||||
if enhancement_type == "linkedin_optimized":
|
||||
enhanced_image = self._apply_linkedin_enhancements(image, content_context)
|
||||
elif enhancement_type == "professional":
|
||||
enhanced_image = self._apply_professional_enhancements(image)
|
||||
elif enhancement_type == "creative":
|
||||
enhanced_image = self._apply_creative_enhancements(image)
|
||||
else:
|
||||
enhanced_image = self._apply_linkedin_enhancements(image, content_context)
|
||||
|
||||
# Convert back to bytes
|
||||
output_buffer = BytesIO()
|
||||
enhanced_image.save(output_buffer, format=image.format or "PNG", optimize=True)
|
||||
enhanced_data = output_buffer.getvalue()
|
||||
|
||||
enhancement_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': enhanced_data,
|
||||
'metadata': {
|
||||
'enhancement_type': enhancement_type,
|
||||
'original_size': original_size,
|
||||
'enhanced_size': enhanced_image.size,
|
||||
'enhancement_time': enhancement_time,
|
||||
'content_context': content_context
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in image quality enhancement: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Quality enhancement failed: {str(e)}",
|
||||
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
|
||||
}
|
||||
|
||||
def _enhance_edit_prompt_for_linkedin(
|
||||
self,
|
||||
edit_prompt: str,
|
||||
content_context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Enhance edit prompt for LinkedIn optimization.
|
||||
|
||||
Args:
|
||||
edit_prompt: Original edit prompt
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
Enhanced edit prompt
|
||||
"""
|
||||
industry = content_context.get('industry', 'business')
|
||||
content_type = content_context.get('content_type', 'post')
|
||||
|
||||
linkedin_edit_enhancements = [
|
||||
f"Maintain professional business aesthetic for {industry} industry",
|
||||
f"Ensure mobile-optimized composition for LinkedIn {content_type}",
|
||||
"Keep professional color scheme and typography",
|
||||
"Maintain brand consistency and visual hierarchy",
|
||||
"Optimize for LinkedIn feed viewing and engagement"
|
||||
]
|
||||
|
||||
enhanced_prompt = f"{edit_prompt}\n\n"
|
||||
enhanced_prompt += "\n".join(linkedin_edit_enhancements)
|
||||
|
||||
return enhanced_prompt
|
||||
|
||||
async def _apply_traditional_editing(
|
||||
self,
|
||||
base_image: bytes,
|
||||
edit_prompt: str,
|
||||
content_context: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply traditional image processing based on edit prompt analysis.
|
||||
|
||||
Args:
|
||||
base_image: Base image data in bytes
|
||||
edit_prompt: Description of desired edits
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
Dict containing edited image result
|
||||
"""
|
||||
try:
|
||||
# Open image for processing
|
||||
image = Image.open(BytesIO(base_image))
|
||||
|
||||
# Analyze edit prompt and apply appropriate processing
|
||||
edit_prompt_lower = edit_prompt.lower()
|
||||
|
||||
if any(word in edit_prompt_lower for word in ['brighter', 'light', 'lighting']):
|
||||
image = self._adjust_brightness(image, 1.2)
|
||||
logger.info("Applied brightness adjustment")
|
||||
|
||||
if any(word in edit_prompt_lower for word in ['sharper', 'sharp', 'clear']):
|
||||
image = self._apply_sharpening(image)
|
||||
logger.info("Applied sharpening")
|
||||
|
||||
if any(word in edit_prompt_lower for word in ['warmer', 'warm', 'color']):
|
||||
image = self._adjust_color_temperature(image, 'warm')
|
||||
logger.info("Applied warm color adjustment")
|
||||
|
||||
if any(word in edit_prompt_lower for word in ['professional', 'business']):
|
||||
image = self._apply_professional_enhancements(image)
|
||||
logger.info("Applied professional enhancements")
|
||||
|
||||
# Convert back to bytes
|
||||
output_buffer = BytesIO()
|
||||
image.save(output_buffer, format=image.format or "PNG", optimize=True)
|
||||
edited_data = output_buffer.getvalue()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': edited_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in traditional editing: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Traditional editing failed: {str(e)}"
|
||||
}
|
||||
|
||||
def _apply_linkedin_enhancements(
|
||||
self,
|
||||
image: Image.Image,
|
||||
content_context: Optional[Dict[str, Any]] = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Apply LinkedIn-specific image enhancements.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
Enhanced image
|
||||
"""
|
||||
try:
|
||||
# Apply standard LinkedIn optimizations
|
||||
image = self._adjust_brightness(image, self.enhancement_factors['brightness'])
|
||||
image = self._adjust_contrast(image, self.enhancement_factors['contrast'])
|
||||
image = self._apply_sharpening(image)
|
||||
image = self._adjust_saturation(image, self.enhancement_factors['saturation'])
|
||||
|
||||
# Ensure professional appearance
|
||||
image = self._ensure_professional_appearance(image, content_context)
|
||||
|
||||
return image
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying LinkedIn enhancements: {str(e)}")
|
||||
return image
|
||||
|
||||
def _apply_professional_enhancements(self, image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Apply professional business aesthetic enhancements.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
|
||||
Returns:
|
||||
Enhanced image
|
||||
"""
|
||||
try:
|
||||
# Subtle enhancements for professional appearance
|
||||
image = self._adjust_brightness(image, 1.05)
|
||||
image = self._adjust_contrast(image, 1.03)
|
||||
image = self._apply_sharpening(image)
|
||||
|
||||
return image
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying professional enhancements: {str(e)}")
|
||||
return image
|
||||
|
||||
def _apply_creative_enhancements(self, image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Apply creative and engaging enhancements.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
|
||||
Returns:
|
||||
Enhanced image
|
||||
"""
|
||||
try:
|
||||
# More pronounced enhancements for creative appeal
|
||||
image = self._adjust_brightness(image, 1.1)
|
||||
image = self._adjust_contrast(image, 1.08)
|
||||
image = self._adjust_saturation(image, 1.1)
|
||||
image = self._apply_sharpening(image)
|
||||
|
||||
return image
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying creative enhancements: {str(e)}")
|
||||
return image
|
||||
|
||||
def _adjust_brightness(self, image: Image.Image, factor: float) -> Image.Image:
|
||||
"""Adjust image brightness."""
|
||||
try:
|
||||
enhancer = ImageEnhance.Brightness(image)
|
||||
return enhancer.enhance(factor)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adjusting brightness: {str(e)}")
|
||||
return image
|
||||
|
||||
def _adjust_contrast(self, image: Image.Image, factor: float) -> Image.Image:
|
||||
"""Adjust image contrast."""
|
||||
try:
|
||||
enhancer = ImageEnhance.Contrast(image)
|
||||
return enhancer.enhance(factor)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adjusting contrast: {str(e)}")
|
||||
return image
|
||||
|
||||
def _adjust_saturation(self, image: Image.Image, factor: float) -> Image.Image:
|
||||
"""Adjust image saturation."""
|
||||
try:
|
||||
enhancer = ImageEnhance.Color(image)
|
||||
return enhancer.enhance(factor)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adjusting saturation: {str(e)}")
|
||||
return image
|
||||
|
||||
def _apply_sharpening(self, image: Image.Image) -> Image.Image:
|
||||
"""Apply image sharpening."""
|
||||
try:
|
||||
# Apply unsharp mask for professional sharpening
|
||||
return image.filter(ImageFilter.UnsharpMask(radius=1, percent=150, threshold=3))
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying sharpening: {str(e)}")
|
||||
return image
|
||||
|
||||
def _adjust_color_temperature(self, image: Image.Image, temperature: str) -> Image.Image:
|
||||
"""Adjust image color temperature."""
|
||||
try:
|
||||
if temperature == 'warm':
|
||||
# Apply warm color adjustment
|
||||
enhancer = ImageEnhance.Color(image)
|
||||
image = enhancer.enhance(1.1)
|
||||
|
||||
# Slight red tint for warmth
|
||||
# This is a simplified approach - more sophisticated color grading could be implemented
|
||||
return image
|
||||
else:
|
||||
return image
|
||||
except Exception as e:
|
||||
logger.error(f"Error adjusting color temperature: {str(e)}")
|
||||
return image
|
||||
|
||||
def _ensure_professional_appearance(
|
||||
self,
|
||||
image: Image.Image,
|
||||
content_context: Optional[Dict[str, Any]] = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Ensure image meets professional LinkedIn standards.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
Professionally optimized image
|
||||
"""
|
||||
try:
|
||||
# Ensure minimum quality standards
|
||||
if image.mode in ('RGBA', 'LA', 'P'):
|
||||
# Convert to RGB for better compatibility
|
||||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||||
if image.mode == 'P':
|
||||
image = image.convert('RGBA')
|
||||
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
|
||||
image = background
|
||||
|
||||
# Ensure minimum resolution for LinkedIn
|
||||
min_resolution = (1024, 1024)
|
||||
if image.size[0] < min_resolution[0] or image.size[1] < min_resolution[1]:
|
||||
# Resize to minimum resolution while maintaining aspect ratio
|
||||
ratio = max(min_resolution[0] / image.size[0], min_resolution[1] / image.size[1])
|
||||
new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio))
|
||||
image = image.resize(new_size, Image.Resampling.LANCZOS)
|
||||
logger.info(f"Resized image to {new_size} for LinkedIn professional standards")
|
||||
|
||||
return image
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring professional appearance: {str(e)}")
|
||||
return image
|
||||
|
||||
async def get_editing_suggestions(
|
||||
self,
|
||||
image_data: bytes,
|
||||
content_context: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get AI-powered editing suggestions for LinkedIn image.
|
||||
|
||||
Args:
|
||||
image_data: Image data in bytes
|
||||
content_context: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
List of editing suggestions
|
||||
"""
|
||||
try:
|
||||
# Analyze image and provide contextual suggestions
|
||||
suggestions = []
|
||||
|
||||
# Professional enhancement suggestions
|
||||
suggestions.append({
|
||||
'id': 'professional_enhancement',
|
||||
'title': 'Professional Enhancement',
|
||||
'description': 'Apply subtle professional enhancements for business appeal',
|
||||
'prompt': 'Enhance this image with professional business aesthetics',
|
||||
'priority': 'high'
|
||||
})
|
||||
|
||||
# Mobile optimization suggestions
|
||||
suggestions.append({
|
||||
'id': 'mobile_optimization',
|
||||
'title': 'Mobile Optimization',
|
||||
'description': 'Optimize for LinkedIn mobile feed viewing',
|
||||
'prompt': 'Optimize this image for mobile LinkedIn viewing',
|
||||
'priority': 'medium'
|
||||
})
|
||||
|
||||
# Industry-specific suggestions
|
||||
industry = content_context.get('industry', 'business')
|
||||
suggestions.append({
|
||||
'id': 'industry_optimization',
|
||||
'title': f'{industry.title()} Industry Optimization',
|
||||
'description': f'Apply {industry} industry-specific visual enhancements',
|
||||
'prompt': f'Enhance this image with {industry} industry aesthetics',
|
||||
'priority': 'medium'
|
||||
})
|
||||
|
||||
# Engagement optimization suggestions
|
||||
suggestions.append({
|
||||
'id': 'engagement_optimization',
|
||||
'title': 'Engagement Optimization',
|
||||
'description': 'Make this image more engaging for LinkedIn audience',
|
||||
'prompt': 'Make this image more engaging and shareable for LinkedIn',
|
||||
'priority': 'low'
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting editing suggestions: {str(e)}")
|
||||
return []
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
LinkedIn Image Generator Service
|
||||
|
||||
This service generates LinkedIn-optimized images using Google's Gemini API.
|
||||
It provides professional, business-appropriate imagery for LinkedIn content.
|
||||
This service generates LinkedIn-optimized images using the common
|
||||
llm_providers infrastructure. It provides professional, business-appropriate
|
||||
imagery for LinkedIn content.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,6 +18,7 @@ from io import BytesIO
|
||||
# Import existing infrastructure
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
from ...llm_providers.main_image_generation import generate_image
|
||||
from ...llm_providers.main_image_editing import edit_image as common_edit_image
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,9 +26,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class LinkedInImageGenerator:
|
||||
"""
|
||||
Handles LinkedIn-optimized image generation using Gemini API.
|
||||
Handles LinkedIn-optimized image generation using common infrastructure.
|
||||
|
||||
This service integrates with the existing Gemini provider infrastructure
|
||||
This service integrates with the llm_providers image generation system
|
||||
and provides LinkedIn-specific image optimization, quality assurance,
|
||||
and professional business aesthetics.
|
||||
"""
|
||||
@@ -36,10 +38,9 @@ class LinkedInImageGenerator:
|
||||
Initialize the LinkedIn Image Generator.
|
||||
|
||||
Args:
|
||||
api_key_manager: API key manager for Gemini authentication
|
||||
api_key_manager: API key manager for authentication
|
||||
"""
|
||||
self.api_key_manager = api_key_manager or APIKeyManager()
|
||||
self.model = "gemini-2.5-flash-image-preview"
|
||||
self.default_aspect_ratio = "1:1" # LinkedIn post optimal ratio
|
||||
self.max_retries = 3
|
||||
|
||||
@@ -55,16 +56,18 @@ class LinkedInImageGenerator:
|
||||
prompt: str,
|
||||
content_context: Dict[str, Any],
|
||||
aspect_ratio: str = "1:1",
|
||||
style_preference: str = "professional"
|
||||
style_preference: str = "professional",
|
||||
user_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate LinkedIn-optimized image using Gemini API.
|
||||
Generate LinkedIn-optimized image using AI provider.
|
||||
|
||||
Args:
|
||||
prompt: User's image generation prompt
|
||||
content_context: LinkedIn content context (topic, industry, content_type)
|
||||
aspect_ratio: Image aspect ratio (1:1, 16:9, 4:3)
|
||||
aspect_ratio: Image aspect ratio (1:1, 16:9, 4:3, 1.91:1, 1:1.25)
|
||||
style_preference: Style preference (professional, creative, industry-specific)
|
||||
user_id: User ID for tenant provider resolution
|
||||
|
||||
Returns:
|
||||
Dict containing generation result, image data, and metadata
|
||||
@@ -78,8 +81,8 @@ class LinkedInImageGenerator:
|
||||
prompt, content_context, style_preference, aspect_ratio
|
||||
)
|
||||
|
||||
# Generate image using existing Gemini infrastructure
|
||||
generation_result = await self._generate_with_gemini(enhanced_prompt, aspect_ratio)
|
||||
# Generate image using tenant-aware provider selection
|
||||
generation_result = await self._generate_with_provider(enhanced_prompt, aspect_ratio, user_id)
|
||||
|
||||
if not generation_result.get('success'):
|
||||
return {
|
||||
@@ -108,7 +111,7 @@ class LinkedInImageGenerator:
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'content_context': content_context,
|
||||
'generation_time': generation_time,
|
||||
'model_used': self.model,
|
||||
'model_used': generation_result.get('model'),
|
||||
'image_format': processed_image['format'],
|
||||
'image_size': processed_image['size'],
|
||||
'resolution': processed_image['resolution']
|
||||
@@ -131,17 +134,19 @@ class LinkedInImageGenerator:
|
||||
|
||||
async def edit_image(
|
||||
self,
|
||||
base_image: bytes,
|
||||
input_image_bytes: bytes,
|
||||
edit_prompt: str,
|
||||
content_context: Dict[str, Any]
|
||||
content_context: Dict[str, Any],
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Edit existing image using Gemini's conversational editing capabilities.
|
||||
Edit existing image using unified image editing infrastructure.
|
||||
|
||||
Args:
|
||||
base_image: Base image data in bytes
|
||||
input_image_bytes: Input image bytes to edit
|
||||
edit_prompt: Description of desired edits
|
||||
content_context: LinkedIn content context for optimization
|
||||
user_id: User ID for tenant provider resolution and subscription checks
|
||||
|
||||
Returns:
|
||||
Dict containing edited image result and metadata
|
||||
@@ -155,18 +160,46 @@ class LinkedInImageGenerator:
|
||||
edit_prompt, content_context
|
||||
)
|
||||
|
||||
# Use Gemini's image editing capabilities
|
||||
# Note: This will be implemented when Gemini's image editing is fully available
|
||||
# For now, we'll return a placeholder implementation
|
||||
# Use unified image editing system.
|
||||
# common_edit_image() handles: provider resolution, pre-flight validation,
|
||||
# generation, and usage tracking — all via user_id.
|
||||
result = common_edit_image(
|
||||
input_image_bytes=input_image_bytes,
|
||||
prompt=enhanced_edit_prompt,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if result and result.image_bytes:
|
||||
generation_time = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"LinkedIn image edited successfully via provider=%s model=%s in %.2fs",
|
||||
result.provider, result.model, generation_time,
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': result.image_bytes,
|
||||
'image_url': None, # not using URL-based retrieval
|
||||
'width': result.width,
|
||||
'height': result.height,
|
||||
'provider': result.provider,
|
||||
'model': result.model,
|
||||
'metadata': {
|
||||
'original_prompt': edit_prompt,
|
||||
'enhanced_prompt': enhanced_edit_prompt,
|
||||
'generation_time': generation_time,
|
||||
'content_context': content_context,
|
||||
},
|
||||
}
|
||||
else:
|
||||
logger.warning("LinkedIn image editing returned no result")
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Image editing not yet implemented - coming in next Gemini API update',
|
||||
'generation_time': (datetime.now() - start_time).total_seconds()
|
||||
'error': 'Image editing returned no result',
|
||||
'generation_time': (datetime.now() - start_time).total_seconds(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in LinkedIn image editing: {str(e)}")
|
||||
logger.error(f"Error in LinkedIn image editing: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Image editing failed: {str(e)}",
|
||||
@@ -268,13 +301,16 @@ class LinkedInImageGenerator:
|
||||
|
||||
return enhanced_edit_prompt
|
||||
|
||||
async def _generate_with_gemini(self, prompt: str, aspect_ratio: str) -> Dict[str, Any]:
|
||||
async def _generate_with_provider(self, prompt: str, aspect_ratio: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate image using unified image generation infrastructure.
|
||||
Provider resolution, pre-flight validation, and usage tracking
|
||||
are all handled by generate_image() from main_image_generation.
|
||||
|
||||
Args:
|
||||
prompt: Enhanced prompt for image generation
|
||||
aspect_ratio: Desired aspect ratio
|
||||
user_id: User ID for tenant provider resolution and subscription checks
|
||||
|
||||
Returns:
|
||||
Generation result from image generation provider
|
||||
@@ -285,26 +321,31 @@ class LinkedInImageGenerator:
|
||||
"1:1": (1024, 1024),
|
||||
"16:9": (1920, 1080),
|
||||
"4:3": (1366, 1024),
|
||||
"9:16": (1080, 1920), # Portrait for stories
|
||||
"9:16": (1080, 1920),
|
||||
"1.91:1": (1200, 627), # LinkedIn recommended landscape
|
||||
"1:1.25": (1080, 1350), # LinkedIn recommended portrait
|
||||
}
|
||||
width, height = aspect_map.get(aspect_ratio, (1024, 1024))
|
||||
|
||||
# Use unified image generation system (defaults to provider based on GPT_PROVIDER)
|
||||
# Delegate to unified image generation system.
|
||||
# Generate_image() handles: provider resolution, pre-flight validation,
|
||||
# model auto-detection, generation, and usage tracking.
|
||||
# We do NOT pass explicit provider or model — let generate_image() resolve
|
||||
# them from tenant config and user defaults.
|
||||
result = generate_image(
|
||||
prompt=prompt,
|
||||
options={
|
||||
"provider": "gemini", # LinkedIn uses Gemini by default
|
||||
"model": self.model if hasattr(self, 'model') else None,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if result and result.image_bytes:
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': result.image_bytes,
|
||||
'image_path': None, # No file path, using bytes directly
|
||||
'image_path': None,
|
||||
'width': result.width,
|
||||
'height': result.height,
|
||||
'provider': result.provider,
|
||||
@@ -487,6 +528,9 @@ class LinkedInImageGenerator:
|
||||
(1.6, 1.8), # 16:9 (landscape)
|
||||
(0.7, 0.8), # 4:3 (portrait)
|
||||
(1.2, 1.4), # 5:4 (landscape)
|
||||
(1.85, 2.0), # 1.91:1 (LinkedIn recommended landscape)
|
||||
(0.6, 0.72), # 1:1.25 (LinkedIn recommended portrait, ~0.8)
|
||||
(0.65, 0.85), # 1:1.25 broader match
|
||||
]
|
||||
|
||||
for min_ratio, max_ratio in suitable_ratios:
|
||||
|
||||
@@ -6,8 +6,10 @@ It provides secure storage, efficient retrieval, and metadata management for gen
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -58,6 +60,8 @@ class LinkedInImageStorage:
|
||||
self.max_storage_size_gb = 10 # Maximum storage size in GB
|
||||
self.image_retention_days = 30 # Days to keep images
|
||||
self.max_image_size_mb = 10 # Maximum individual image size in MB
|
||||
self.max_images_per_user = 100 # Maximum images per user
|
||||
self._uuid_pattern = re.compile(r'^[a-f0-9]{16}$')
|
||||
|
||||
logger.info(f"LinkedIn Image Storage initialized at {self.base_storage_path}")
|
||||
|
||||
@@ -102,6 +106,22 @@ class LinkedInImageStorage:
|
||||
try:
|
||||
start_time = datetime.now()
|
||||
|
||||
# Check per-user storage quota
|
||||
if user_id:
|
||||
user_count = await self._count_user_images(user_id)
|
||||
if user_count >= self.max_images_per_user:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"User image limit ({self.max_images_per_user}) reached. Delete existing images or increase limit."
|
||||
}
|
||||
|
||||
# Check disk space
|
||||
if not await self._check_disk_space(len(image_data)):
|
||||
return {
|
||||
'success': False,
|
||||
'error': "Insufficient disk space for image storage."
|
||||
}
|
||||
|
||||
# Generate unique image ID
|
||||
image_id = self._generate_image_id(image_data, metadata)
|
||||
|
||||
@@ -170,6 +190,9 @@ class LinkedInImageStorage:
|
||||
Dict containing image data and metadata
|
||||
"""
|
||||
try:
|
||||
if not self._validate_image_id(image_id):
|
||||
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||
|
||||
# Find image file
|
||||
image_path = await self._find_image_by_id(image_id, user_id)
|
||||
if not image_path:
|
||||
@@ -216,6 +239,9 @@ class LinkedInImageStorage:
|
||||
Dict containing deletion result
|
||||
"""
|
||||
try:
|
||||
if not self._validate_image_id(image_id):
|
||||
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||
|
||||
# Find image file
|
||||
image_path = await self._find_image_by_id(image_id, user_id)
|
||||
if not image_path:
|
||||
@@ -418,6 +444,32 @@ class LinkedInImageStorage:
|
||||
'error': f"Failed to get storage stats: {str(e)}"
|
||||
}
|
||||
|
||||
def _validate_image_id(self, image_id: str) -> bool:
|
||||
"""Validate image_id against expected format to prevent path traversal."""
|
||||
return bool(self._uuid_pattern.match(image_id))
|
||||
|
||||
async def _count_user_images(self, user_id: str) -> int:
|
||||
"""Count total images stored for a given user."""
|
||||
try:
|
||||
images_path, _ = self._get_workspace_paths(user_id)
|
||||
count = 0
|
||||
if images_path.exists():
|
||||
for content_dir in images_path.iterdir():
|
||||
if content_dir.is_dir():
|
||||
count += sum(1 for f in content_dir.glob("*.png") if f.is_file())
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning(f"Error counting images for user {user_id}: {e}")
|
||||
return 0
|
||||
|
||||
async def _check_disk_space(self, required_bytes: int) -> bool:
|
||||
"""Check if sufficient disk space is available."""
|
||||
try:
|
||||
usage = shutil.disk_usage(self.base_storage_path)
|
||||
return usage.free > required_bytes * 2 # require 2x headroom
|
||||
except Exception:
|
||||
return True # if we can't check, allow the write
|
||||
|
||||
def _generate_image_id(self, image_data: bytes, metadata: Dict[str, Any]) -> str:
|
||||
"""Generate unique image ID based on content and metadata."""
|
||||
# Create hash from image data and key metadata
|
||||
@@ -569,6 +621,9 @@ class LinkedInImageStorage:
|
||||
Returns:
|
||||
Dict containing image metadata if found
|
||||
"""
|
||||
if not self._validate_image_id(image_id):
|
||||
logger.warning(f"Invalid image ID format in metadata request: {image_id}")
|
||||
return None
|
||||
return await self._load_metadata(image_id, user_id)
|
||||
|
||||
async def _load_metadata(self, image_id: str, user_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
LinkedIn Image Prompts Package
|
||||
|
||||
This package provides AI-powered image prompt generation for LinkedIn content
|
||||
using Google's Gemini API. It creates three distinct prompt styles optimized
|
||||
for professional business image generation.
|
||||
using the provider-agnostic llm_text_gen gateway. It creates three distinct
|
||||
prompt styles optimized for professional business image generation.
|
||||
"""
|
||||
|
||||
from .linkedin_prompt_generator import LinkedInPromptGenerator
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
LinkedIn Image Prompt Generator Service
|
||||
|
||||
This service generates AI-optimized image prompts for LinkedIn content using Gemini's
|
||||
capabilities. It creates three distinct prompt styles (professional, creative, industry-specific)
|
||||
following best practices for image generation.
|
||||
This service generates AI-optimized image prompts for LinkedIn content using
|
||||
the provider-agnostic llm_text_gen gateway. It creates three distinct prompt
|
||||
styles (professional, creative, industry-specific) following best practices
|
||||
for image generation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,14 +14,14 @@ from loguru import logger
|
||||
|
||||
# Import existing infrastructure
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
from ...llm_providers.gemini_provider import gemini_text_response
|
||||
from ...llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
class LinkedInPromptGenerator:
|
||||
"""
|
||||
Generates AI-optimized image prompts for LinkedIn content.
|
||||
|
||||
This service creates three distinct prompt styles following Gemini API best practices:
|
||||
This service creates three distinct prompt styles following best practices:
|
||||
1. Professional Style - Corporate aesthetics, clean lines, business colors
|
||||
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
|
||||
3. Industry-Specific Style - Tailored to specific business sectors
|
||||
@@ -31,10 +32,9 @@ class LinkedInPromptGenerator:
|
||||
Initialize the LinkedIn Prompt Generator.
|
||||
|
||||
Args:
|
||||
api_key_manager: API key manager for Gemini authentication
|
||||
api_key_manager: API key manager for authentication
|
||||
"""
|
||||
self.api_key_manager = api_key_manager or APIKeyManager()
|
||||
self.model = "gemini-2.0-flash-exp"
|
||||
|
||||
# Prompt generation configuration
|
||||
self.max_prompt_length = 500
|
||||
@@ -49,7 +49,8 @@ class LinkedInPromptGenerator:
|
||||
async def generate_three_prompts(
|
||||
self,
|
||||
linkedin_content: Dict[str, Any],
|
||||
aspect_ratio: str = "1:1"
|
||||
aspect_ratio: str = "1:1",
|
||||
user_id: str = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate three AI-optimized image prompts for LinkedIn content.
|
||||
@@ -57,6 +58,7 @@ class LinkedInPromptGenerator:
|
||||
Args:
|
||||
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
|
||||
aspect_ratio: Desired image aspect ratio
|
||||
user_id: User ID for subscription checking
|
||||
|
||||
Returns:
|
||||
List of three prompt objects with style, prompt, and description
|
||||
@@ -65,11 +67,11 @@ class LinkedInPromptGenerator:
|
||||
start_time = datetime.now()
|
||||
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
|
||||
|
||||
# Generate prompts using Gemini
|
||||
prompts = await self._generate_prompts_with_gemini(linkedin_content, aspect_ratio)
|
||||
# Generate prompts using provider-agnostic gateway
|
||||
prompts = await self._generate_prompts_with_llm(linkedin_content, aspect_ratio, user_id)
|
||||
|
||||
if not prompts or len(prompts) < 3:
|
||||
logger.warning("Gemini prompt generation failed, using fallback prompts")
|
||||
logger.warning("Prompt generation failed, using fallback prompts")
|
||||
prompts = self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||
|
||||
# Ensure exactly 3 prompts
|
||||
@@ -92,62 +94,65 @@ class LinkedInPromptGenerator:
|
||||
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
|
||||
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||
|
||||
async def _generate_prompts_with_gemini(
|
||||
async def _generate_prompts_with_llm(
|
||||
self,
|
||||
linkedin_content: Dict[str, Any],
|
||||
aspect_ratio: str
|
||||
aspect_ratio: str,
|
||||
user_id: str = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate image prompts using Gemini AI.
|
||||
Generate image prompts using provider-agnostic llm_text_gen.
|
||||
|
||||
Args:
|
||||
linkedin_content: LinkedIn content context
|
||||
aspect_ratio: Image aspect ratio
|
||||
user_id: User ID for subscription checking
|
||||
|
||||
Returns:
|
||||
List of generated prompts
|
||||
"""
|
||||
try:
|
||||
# Build the prompt for Gemini
|
||||
gemini_prompt = self._build_gemini_prompt(linkedin_content, aspect_ratio)
|
||||
# Build the prompt
|
||||
prompt = self._build_image_prompt(linkedin_content, aspect_ratio)
|
||||
|
||||
# Generate response using Gemini
|
||||
response = gemini_text_response(
|
||||
prompt=gemini_prompt,
|
||||
temperature=0.7,
|
||||
top_p=0.8,
|
||||
n=1,
|
||||
# Generate response using provider-agnostic gateway
|
||||
response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization.",
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_image_prompts",
|
||||
max_tokens=1000,
|
||||
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization."
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.warning("No response from Gemini prompt generation")
|
||||
logger.warning("No response from prompt generation")
|
||||
return []
|
||||
|
||||
# Parse Gemini response into structured prompts
|
||||
prompts = self._parse_gemini_response(response, linkedin_content)
|
||||
# Parse response into structured prompts
|
||||
response_text = response if isinstance(response, str) else str(response or "")
|
||||
prompts = self._parse_llm_response(response_text, linkedin_content)
|
||||
|
||||
return prompts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Gemini prompt generation: {str(e)}")
|
||||
logger.error(f"Error in prompt generation: {str(e)}")
|
||||
return []
|
||||
|
||||
def _build_gemini_prompt(
|
||||
def _build_image_prompt(
|
||||
self,
|
||||
linkedin_content: Dict[str, Any],
|
||||
aspect_ratio: str
|
||||
) -> str:
|
||||
"""
|
||||
Build comprehensive prompt for Gemini to generate image prompts.
|
||||
Build comprehensive prompt for LLM to generate image prompts.
|
||||
|
||||
Args:
|
||||
linkedin_content: LinkedIn content context
|
||||
aspect_ratio: Image aspect ratio
|
||||
|
||||
Returns:
|
||||
Formatted prompt for Gemini
|
||||
Formatted prompt for LLM
|
||||
"""
|
||||
topic = linkedin_content.get('topic', 'business')
|
||||
industry = linkedin_content.get('industry', 'business')
|
||||
@@ -428,16 +433,16 @@ class LinkedInPromptGenerator:
|
||||
else:
|
||||
return 'Informational & Awareness'
|
||||
|
||||
def _parse_gemini_response(
|
||||
def _parse_llm_response(
|
||||
self,
|
||||
response: str,
|
||||
linkedin_content: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse Gemini response into structured prompt objects.
|
||||
Parse LLM response into structured prompt objects.
|
||||
|
||||
Args:
|
||||
response: Raw response from Gemini
|
||||
response: Raw response from LLM
|
||||
linkedin_content: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
@@ -462,7 +467,7 @@ class LinkedInPromptGenerator:
|
||||
return self._parse_response_manually(response, linkedin_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Gemini response: {str(e)}")
|
||||
logger.error(f"Error parsing LLM response: {str(e)}")
|
||||
return self._parse_response_manually(response, linkedin_content)
|
||||
|
||||
def _parse_response_manually(
|
||||
@@ -474,7 +479,7 @@ class LinkedInPromptGenerator:
|
||||
Manually parse response if JSON parsing fails.
|
||||
|
||||
Args:
|
||||
response: Raw response from Gemini
|
||||
response: Raw response from LLM
|
||||
linkedin_content: LinkedIn content context
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Research Handler for LinkedIn Content Generation
|
||||
|
||||
Handles research operations and timing for content generation.
|
||||
Uses common Exa/Tavily infrastructure with pre-flight validation.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
from models.linkedin_models import ResearchSource
|
||||
@@ -21,11 +22,19 @@ class ResearchHandler:
|
||||
request,
|
||||
research_enabled: bool,
|
||||
search_engine: str,
|
||||
max_results: int = 10
|
||||
max_results: int = 10,
|
||||
user_id: Optional[str] = None
|
||||
) -> tuple[List[ResearchSource], float]:
|
||||
"""
|
||||
Conduct research if enabled and return sources with timing.
|
||||
|
||||
Args:
|
||||
request: Generation request object
|
||||
research_enabled: Whether research is enabled
|
||||
search_engine: Search engine to use (exa, tavily)
|
||||
max_results: Maximum number of results
|
||||
user_id: User ID for pre-flight validation and usage tracking
|
||||
|
||||
Returns:
|
||||
Tuple of (research_sources, research_time)
|
||||
"""
|
||||
@@ -33,7 +42,6 @@ class ResearchHandler:
|
||||
research_time = 0
|
||||
|
||||
if research_enabled:
|
||||
# Debug: Log the search engine value being passed
|
||||
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
|
||||
|
||||
research_start = datetime.now()
|
||||
@@ -41,7 +49,8 @@ class ResearchHandler:
|
||||
topic=request.topic,
|
||||
industry=request.industry,
|
||||
search_engine=search_engine,
|
||||
max_results=max_results
|
||||
max_results=max_results,
|
||||
user_id=user_id
|
||||
)
|
||||
research_time = (datetime.now() - research_start).total_seconds()
|
||||
logger.info(f"Research completed in {research_time:.2f}s, found {len(research_sources)} sources")
|
||||
@@ -67,10 +76,5 @@ class ResearchHandler:
|
||||
if not research_enabled or level == 'none':
|
||||
return False
|
||||
|
||||
# For Google native grounding, Gemini returns sources in the generation metadata,
|
||||
# so we should not require pre-fetched research_sources.
|
||||
if engine_str == 'google':
|
||||
return True
|
||||
|
||||
# For other engines, require that research actually returned sources
|
||||
return bool(research_sources)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
LinkedIn Content Generation Service for ALwrity
|
||||
|
||||
This service generates various types of LinkedIn content with enhanced grounding capabilities.
|
||||
Integrated with Google Search, Gemini Grounded Provider, and quality analysis.
|
||||
This service generates various types of LinkedIn content with provider-agnostic
|
||||
LLM access via llm_text_gen. Research is handled by Exa/Tavily through the
|
||||
common research infrastructure.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -21,57 +22,44 @@ from models.linkedin_models import (
|
||||
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
|
||||
GroundingLevel
|
||||
)
|
||||
from services.research import GoogleSearchService
|
||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||||
from services.citation import CitationManager
|
||||
from services.quality import ContentQualityAnalyzer
|
||||
|
||||
|
||||
class LinkedInService:
|
||||
"""
|
||||
Enhanced LinkedIn content generation service with grounding capabilities.
|
||||
LinkedIn content generation service with provider-agnostic LLM access.
|
||||
|
||||
This service integrates real research, grounded content generation,
|
||||
citation management, and quality analysis for enterprise-grade content.
|
||||
Uses llm_text_gen for text generation (respects GPT_PROVIDER).
|
||||
Uses Exa/Tavily for research via common infrastructure.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the LinkedIn service with all required components."""
|
||||
# Google Search Service not used - removed to avoid false warnings
|
||||
self.google_search = None
|
||||
"""Initialize the LinkedIn service with lazy provider initialization."""
|
||||
self._citation_manager = None
|
||||
self._quality_analyzer = None
|
||||
|
||||
@property
|
||||
def citation_manager(self):
|
||||
if self._citation_manager is None:
|
||||
try:
|
||||
self.gemini_grounded = GeminiGroundedProvider()
|
||||
logger.info("✅ Gemini Grounded Provider initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Gemini Grounded Provider not available: {e}")
|
||||
self.gemini_grounded = None
|
||||
|
||||
try:
|
||||
self.citation_manager = CitationManager()
|
||||
self._citation_manager = CitationManager()
|
||||
logger.info("✅ Citation Manager initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Citation Manager not available: {e}")
|
||||
self.citation_manager = None
|
||||
self._citation_manager = None
|
||||
return self._citation_manager
|
||||
|
||||
@property
|
||||
def quality_analyzer(self):
|
||||
if self._quality_analyzer is None:
|
||||
try:
|
||||
self.quality_analyzer = ContentQualityAnalyzer()
|
||||
self._quality_analyzer = ContentQualityAnalyzer()
|
||||
logger.info("✅ Content Quality Analyzer initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
|
||||
self.quality_analyzer = None
|
||||
|
||||
# Initialize fallback provider for non-grounded content
|
||||
try:
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response, gemini_text_response
|
||||
self.fallback_provider = {
|
||||
'generate_structured_json': gemini_structured_json_response,
|
||||
'generate_text': gemini_text_response
|
||||
}
|
||||
logger.info("✅ Fallback Gemini provider initialized")
|
||||
except ImportError as e:
|
||||
logger.warning(f"⚠️ Fallback Gemini provider not available: {e}")
|
||||
self.fallback_provider = None
|
||||
self._quality_analyzer = None
|
||||
return self._quality_analyzer
|
||||
|
||||
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
|
||||
"""
|
||||
@@ -94,8 +82,9 @@ class LinkedInService:
|
||||
# Step 1: Conduct research if enabled
|
||||
from services.linkedin.research_handler import ResearchHandler
|
||||
research_handler = ResearchHandler(self)
|
||||
user_id = str(getattr(request, 'user_id', '') or '')
|
||||
research_sources, research_time = await research_handler.conduct_research(
|
||||
request, request.research_enabled, request.search_engine, 10
|
||||
request, request.research_enabled, request.search_engine, 10, user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate content based on grounding level
|
||||
@@ -105,15 +94,14 @@ class LinkedInService:
|
||||
from services.linkedin.content_generator import ContentGenerator
|
||||
content_generator = ContentGenerator(
|
||||
self.citation_manager,
|
||||
self.quality_analyzer,
|
||||
self.gemini_grounded,
|
||||
self.fallback_provider
|
||||
self.quality_analyzer
|
||||
)
|
||||
|
||||
if grounding_enabled:
|
||||
content_result = await content_generator.generate_grounded_post_content(
|
||||
request=request,
|
||||
research_sources=research_sources
|
||||
research_sources=research_sources,
|
||||
user_id=str(getattr(request, 'user_id', ''))
|
||||
)
|
||||
else:
|
||||
logger.error("Grounding not enabled, Error generating LinkedIn post")
|
||||
@@ -152,8 +140,9 @@ class LinkedInService:
|
||||
# Step 1: Conduct research if enabled
|
||||
from services.linkedin.research_handler import ResearchHandler
|
||||
research_handler = ResearchHandler(self)
|
||||
user_id = str(getattr(request, 'user_id', '') or '')
|
||||
research_sources, research_time = await research_handler.conduct_research(
|
||||
request, request.research_enabled, request.search_engine, 15
|
||||
request, request.research_enabled, request.search_engine, 15, user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate content based on grounding level
|
||||
@@ -163,15 +152,14 @@ class LinkedInService:
|
||||
from services.linkedin.content_generator import ContentGenerator
|
||||
content_generator = ContentGenerator(
|
||||
self.citation_manager,
|
||||
self.quality_analyzer,
|
||||
self.gemini_grounded,
|
||||
self.fallback_provider
|
||||
self.quality_analyzer
|
||||
)
|
||||
|
||||
if grounding_enabled:
|
||||
content_result = await content_generator.generate_grounded_article_content(
|
||||
request=request,
|
||||
research_sources=research_sources
|
||||
research_sources=research_sources,
|
||||
user_id=str(getattr(request, 'user_id', ''))
|
||||
)
|
||||
else:
|
||||
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
|
||||
@@ -210,8 +198,9 @@ class LinkedInService:
|
||||
# Step 1: Conduct research if enabled
|
||||
from services.linkedin.research_handler import ResearchHandler
|
||||
research_handler = ResearchHandler(self)
|
||||
user_id = str(getattr(request, 'user_id', '') or '')
|
||||
research_sources, research_time = await research_handler.conduct_research(
|
||||
request, request.research_enabled, request.search_engine, 12
|
||||
request, request.research_enabled, request.search_engine, 12, user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate content based on grounding level
|
||||
@@ -221,15 +210,14 @@ class LinkedInService:
|
||||
from services.linkedin.content_generator import ContentGenerator
|
||||
content_generator = ContentGenerator(
|
||||
self.citation_manager,
|
||||
self.quality_analyzer,
|
||||
self.gemini_grounded,
|
||||
self.fallback_provider
|
||||
self.quality_analyzer
|
||||
)
|
||||
|
||||
if grounding_enabled:
|
||||
content_result = await content_generator.generate_grounded_carousel_content(
|
||||
request=request,
|
||||
research_sources=research_sources
|
||||
research_sources=research_sources,
|
||||
user_id=str(getattr(request, 'user_id', ''))
|
||||
)
|
||||
else:
|
||||
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
|
||||
@@ -303,8 +291,9 @@ class LinkedInService:
|
||||
# Step 1: Conduct research if enabled
|
||||
from services.linkedin.research_handler import ResearchHandler
|
||||
research_handler = ResearchHandler(self)
|
||||
user_id = str(getattr(request, 'user_id', '') or '')
|
||||
research_sources, research_time = await research_handler.conduct_research(
|
||||
request, request.research_enabled, request.search_engine, 8
|
||||
request, request.research_enabled, request.search_engine, 8, user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate content based on grounding level
|
||||
@@ -314,15 +303,14 @@ class LinkedInService:
|
||||
from services.linkedin.content_generator import ContentGenerator
|
||||
content_generator = ContentGenerator(
|
||||
self.citation_manager,
|
||||
self.quality_analyzer,
|
||||
self.gemini_grounded,
|
||||
self.fallback_provider
|
||||
self.quality_analyzer
|
||||
)
|
||||
|
||||
if grounding_enabled:
|
||||
content_result = await content_generator.generate_grounded_video_script_content(
|
||||
request=request,
|
||||
research_sources=research_sources
|
||||
research_sources=research_sources,
|
||||
user_id=str(getattr(request, 'user_id', ''))
|
||||
)
|
||||
else:
|
||||
logger.error("Grounding not enabled - cannot generate LinkedIn video script without AI provider")
|
||||
@@ -387,8 +375,9 @@ class LinkedInService:
|
||||
# Step 1: Conduct research if enabled
|
||||
from services.linkedin.research_handler import ResearchHandler
|
||||
research_handler = ResearchHandler(self)
|
||||
user_id = str(getattr(request, 'user_id', '') or '')
|
||||
research_sources, research_time = await research_handler.conduct_research(
|
||||
request, request.research_enabled, request.search_engine, 5
|
||||
request, request.research_enabled, request.search_engine, 5, user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate response based on grounding level
|
||||
@@ -398,15 +387,14 @@ class LinkedInService:
|
||||
from services.linkedin.content_generator import ContentGenerator
|
||||
content_generator = ContentGenerator(
|
||||
self.citation_manager,
|
||||
self.quality_analyzer,
|
||||
self.gemini_grounded,
|
||||
self.fallback_provider
|
||||
self.quality_analyzer
|
||||
)
|
||||
|
||||
if grounding_enabled:
|
||||
response_result = await content_generator.generate_grounded_comment_response(
|
||||
request=request,
|
||||
research_sources=research_sources
|
||||
research_sources=research_sources,
|
||||
user_id=str(getattr(request, 'user_id', ''))
|
||||
)
|
||||
else:
|
||||
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
|
||||
@@ -423,20 +411,13 @@ class LinkedInService:
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
# Convert to LinkedInCommentResponseResult
|
||||
from models.linkedin_models import CommentResponse
|
||||
comment_response = CommentResponse(
|
||||
response=result['response'],
|
||||
alternative_responses=result.get('alternative_responses', []),
|
||||
tone_analysis=result.get('tone_analysis')
|
||||
)
|
||||
|
||||
return LinkedInCommentResponseResult(
|
||||
success=True,
|
||||
data=comment_response,
|
||||
research_sources=result['research_sources'],
|
||||
generation_metadata=result['generation_metadata'],
|
||||
grounding_status=result['grounding_status']
|
||||
response=result['response'],
|
||||
alternative_responses=result.get('alternative_responses', []),
|
||||
tone_analysis=result.get('tone_analysis'),
|
||||
generation_metadata=result.get('generation_metadata', {}),
|
||||
grounding_status=result.get('grounding_status')
|
||||
)
|
||||
else:
|
||||
return LinkedInCommentResponseResult(
|
||||
@@ -451,35 +432,187 @@ class LinkedInService:
|
||||
error=f"Failed to generate LinkedIn comment response: {str(e)}"
|
||||
)
|
||||
|
||||
async def _conduct_research(self, topic: str, industry: str, search_engine: str, max_results: int = 10) -> List[ResearchSource]:
|
||||
async def _conduct_research(self, topic: str, industry: str, search_engine: str, max_results: int = 10, user_id: str = None) -> List[ResearchSource]:
|
||||
"""
|
||||
Use native Google Search grounding instead of custom search.
|
||||
The Gemini API handles search automatically when the google_search tool is enabled.
|
||||
Conduct research using the configured search engine with caching.
|
||||
|
||||
For Exa: delegates to ExaResearchProvider.simple_search() with pre-flight validation
|
||||
For Tavily: delegates to TavilyService.search() with pre-flight validation
|
||||
For Google/unknown: falls back to Exa if available
|
||||
|
||||
Args:
|
||||
topic: Research topic
|
||||
industry: Target industry
|
||||
search_engine: Search engine to use (google uses native grounding)
|
||||
search_engine: Search engine to use (exa, tavily)
|
||||
max_results: Maximum number of results to return
|
||||
user_id: User ID for subscription pre-flight validation and usage tracking
|
||||
|
||||
Returns:
|
||||
List of research sources (empty for google - sources come from grounding metadata)
|
||||
List of research sources
|
||||
"""
|
||||
try:
|
||||
# Debug: Log the search engine value received
|
||||
logger.info(f"Received search engine: '{search_engine}' (type: {type(search_engine)})")
|
||||
from services.cache.research_cache import research_cache
|
||||
|
||||
search_engine_lower = search_engine.lower().strip()
|
||||
|
||||
# Default to Exa if Google or unknown engine specified
|
||||
if search_engine_lower in ("google", ""):
|
||||
logger.info(f"Search engine '{search_engine}' not supported for direct research, defaulting to Exa")
|
||||
search_engine_lower = "exa"
|
||||
|
||||
# Check cache first
|
||||
cached_result = research_cache.get_cached_result(
|
||||
keywords=[topic],
|
||||
industry=industry,
|
||||
target_audience="linkedin"
|
||||
)
|
||||
|
||||
if cached_result:
|
||||
logger.info(f"Returning cached research result for topic: {topic[:50]}")
|
||||
# Convert cached dict back to ResearchSource objects
|
||||
sources = []
|
||||
for r in cached_result:
|
||||
sources.append(ResearchSource(
|
||||
title=r.get('title', 'Untitled'),
|
||||
url=r.get('url', ''),
|
||||
content=r.get('content', '')[:500],
|
||||
relevance_score=r.get('relevance_score', 0.5),
|
||||
credibility_score=r.get('credibility_score', 0.5),
|
||||
source_type=r.get('source_type', 'web'),
|
||||
publication_date=r.get('publication_date')
|
||||
))
|
||||
return sources
|
||||
|
||||
try:
|
||||
# Pre-flight validation if user_id provided
|
||||
if user_id:
|
||||
try:
|
||||
from services.subscription.preflight_validator import validate_exa_research_operations
|
||||
from services.database import get_session_for_user
|
||||
from services.subscription import PricingService
|
||||
import os
|
||||
|
||||
db_val = get_session_for_user(user_id)
|
||||
if db_val:
|
||||
try:
|
||||
pricing_service = PricingService(db_val)
|
||||
gpt_provider = os.getenv("GPT_PROVIDER", "google")
|
||||
validate_exa_research_operations(pricing_service, user_id, gpt_provider)
|
||||
finally:
|
||||
db_val.close()
|
||||
except Exception as preflight_err:
|
||||
logger.warning(f"Research pre-flight validation failed: {preflight_err}")
|
||||
# Continue anyway - don't block research for pre-flight issues
|
||||
|
||||
if search_engine_lower == "exa":
|
||||
from services.research import get_exa_content_provider
|
||||
|
||||
try:
|
||||
provider = get_exa_content_provider()
|
||||
except RuntimeError:
|
||||
logger.warning("Exa API key not configured, falling back to Tavily")
|
||||
provider = None
|
||||
|
||||
if provider:
|
||||
try:
|
||||
results = await provider.simple_search(
|
||||
query=f"{topic} {industry}",
|
||||
num_results=max_results,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
sources = []
|
||||
for r in results:
|
||||
sources.append(ResearchSource(
|
||||
title=r.get('title', 'Untitled'),
|
||||
url=r.get('url', ''),
|
||||
content=r.get('text', '')[:500],
|
||||
relevance_score=r.get('score', 0.5),
|
||||
credibility_score=r.get('score', 0.5),
|
||||
source_type='web',
|
||||
publication_date=r.get('publishedDate')
|
||||
))
|
||||
|
||||
# Cache the results
|
||||
cache_data = [
|
||||
{
|
||||
'title': s.title,
|
||||
'url': s.url,
|
||||
'content': s.content,
|
||||
'relevance_score': s.relevance_score,
|
||||
'credibility_score': s.credibility_score,
|
||||
'source_type': s.source_type,
|
||||
'publication_date': s.publication_date
|
||||
}
|
||||
for s in sources
|
||||
]
|
||||
research_cache.cache_result(
|
||||
keywords=[topic],
|
||||
industry=industry,
|
||||
target_audience="linkedin",
|
||||
result=cache_data
|
||||
)
|
||||
|
||||
logger.info(f"Exa research returned {len(sources)} sources for topic: {topic[:50]}")
|
||||
return sources
|
||||
except Exception as exa_err:
|
||||
logger.warning(f"Exa research failed ({exa_err}), falling back to Tavily")
|
||||
|
||||
# Fallback to Tavily
|
||||
search_engine_lower = "tavily"
|
||||
|
||||
elif search_engine_lower == "tavily":
|
||||
from services.research.tavily_service import TavilyService
|
||||
|
||||
tavily_service = TavilyService()
|
||||
if not tavily_service.enabled:
|
||||
logger.warning("Tavily API key not configured, skipping Tavily research")
|
||||
return []
|
||||
|
||||
result = await tavily_service.search(
|
||||
query=f"{topic} {industry}",
|
||||
max_results=max_results
|
||||
)
|
||||
|
||||
raw_results = result.get('results', []) if isinstance(result, dict) else []
|
||||
sources = []
|
||||
for r in raw_results:
|
||||
sources.append(ResearchSource(
|
||||
title=r.get('title', 'Untitled'),
|
||||
url=r.get('url', ''),
|
||||
content=r.get('content', '')[:500],
|
||||
relevance_score=r.get('score', r.get('relevance_score', 0.5)),
|
||||
credibility_score=r.get('relevance_score', 0.5),
|
||||
source_type='web',
|
||||
publication_date=r.get('published_date')
|
||||
))
|
||||
|
||||
# Cache the results
|
||||
cache_data = [
|
||||
{
|
||||
'title': s.title,
|
||||
'url': s.url,
|
||||
'content': s.content,
|
||||
'relevance_score': s.relevance_score,
|
||||
'credibility_score': s.credibility_score,
|
||||
'source_type': s.source_type,
|
||||
'publication_date': s.publication_date
|
||||
}
|
||||
for s in sources
|
||||
]
|
||||
research_cache.cache_result(
|
||||
keywords=[topic],
|
||||
industry=industry,
|
||||
target_audience="linkedin",
|
||||
result=cache_data
|
||||
)
|
||||
|
||||
logger.info(f"Tavily research returned {len(sources)} sources for topic: {topic[:50]}")
|
||||
return sources
|
||||
|
||||
# Handle both enum value 'google' and enum name 'GOOGLE'
|
||||
if search_engine.lower() == "google":
|
||||
# No need for manual search - Gemini handles it automatically with native grounding
|
||||
logger.info("Using native Google Search grounding via Gemini API - no manual search needed")
|
||||
return [] # Return empty list - sources will come from grounding metadata
|
||||
else:
|
||||
# Fallback to basic research for other search engines
|
||||
logger.error(f"Search engine {search_engine} not fully implemented, using fallback")
|
||||
raise Exception(f"Search engine {search_engine} not fully implemented, using fallback")
|
||||
logger.warning(f"Unknown search engine '{search_engine}', no research performed")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error conducting research: {str(e)}")
|
||||
# Fallback to basic research
|
||||
raise Exception(f"Error conducting research: {str(e)}")
|
||||
logger.error(f"Research failed for engine {search_engine}: {e}")
|
||||
return []
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
LinkedIn Persona Service
|
||||
Handles LinkedIn-specific persona generation and optimization.
|
||||
Uses provider-agnostic llm_text_gen for LLM access.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from .linkedin_persona_prompts import LinkedInPersonaPrompts
|
||||
from .linkedin_persona_schemas import LinkedInPersonaSchemas
|
||||
|
||||
@@ -57,14 +58,15 @@ class LinkedInPersonaService:
|
||||
# Extract user_id for tracking
|
||||
user_id = onboarding_data.get("session_info", {}).get("user_id")
|
||||
|
||||
# Generate structured response using Gemini with optimized prompts
|
||||
response = gemini_structured_json_response(
|
||||
# Generate structured response using provider-agnostic gateway
|
||||
response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
json_struct=schema,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
flow_type="linkedin_persona_generation",
|
||||
max_tokens=4096,
|
||||
temperature=0.2
|
||||
)
|
||||
|
||||
if "error" in response:
|
||||
|
||||
@@ -7,6 +7,7 @@ replacing mock research with real-time industry information.
|
||||
Available Services:
|
||||
- GoogleSearchService: Real-time industry research using Google Custom Search API
|
||||
- ExaService: Competitor discovery and analysis using Exa API
|
||||
- ExaContentResearchProvider: Shared content research provider for LinkedIn/Blog
|
||||
- TavilyService: AI-powered web search with real-time information
|
||||
- Source ranking and credibility assessment
|
||||
- Content extraction and insight generation
|
||||
@@ -17,12 +18,13 @@ Core Module (v2.0):
|
||||
- ParameterOptimizer: AI-driven parameter optimization
|
||||
|
||||
Author: ALwrity Team
|
||||
Version: 2.0
|
||||
Last Updated: December 2025
|
||||
Version: 2.1
|
||||
Last Updated: June 2026
|
||||
"""
|
||||
|
||||
from .google_search_service import GoogleSearchService
|
||||
from .exa_service import ExaService
|
||||
from .exa_content_research import ExaContentResearchProvider, get_exa_content_provider
|
||||
from .tavily_service import TavilyService
|
||||
|
||||
# Core Research Engine (v2.0)
|
||||
@@ -43,6 +45,10 @@ __all__ = [
|
||||
"ExaService",
|
||||
"TavilyService",
|
||||
|
||||
# Shared content research provider
|
||||
"ExaContentResearchProvider",
|
||||
"get_exa_content_provider",
|
||||
|
||||
# Core Research Engine (v2.0)
|
||||
"ResearchEngine",
|
||||
"ResearchContext",
|
||||
|
||||
198
backend/services/research/exa_content_research.py
Normal file
198
backend/services/research/exa_content_research.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Exa Content Research Provider
|
||||
|
||||
Shared Exa neural search provider for content research across ALwrity modules.
|
||||
Provides simple_search() for fact-checking, content grounding, and research.
|
||||
|
||||
Used by:
|
||||
- LinkedIn Writer (content generation research)
|
||||
- Blog Writer (fact-checking and writing assistance)
|
||||
|
||||
This is the content-research variant. For competitor discovery/analysis,
|
||||
use ExaService in exa_service.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import List, Dict, Any, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class ExaContentResearchProvider:
|
||||
"""Exa neural search provider for content research."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Exa content research provider."""
|
||||
self.api_key = os.getenv("EXA_API_KEY")
|
||||
if not self.api_key:
|
||||
raise RuntimeError("EXA_API_KEY not configured")
|
||||
|
||||
from exa_py import Exa
|
||||
self.exa = Exa(self.api_key)
|
||||
logger.info("✅ Exa Content Research Provider initialized")
|
||||
|
||||
async def simple_search(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
user_id: str = None,
|
||||
include_domains: List[str] = None,
|
||||
exclude_domains: List[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Simple Exa search for content research and fact-checking.
|
||||
Handles subscription preflight check and usage tracking.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
num_results: Number of results to return (default 5)
|
||||
user_id: Optional user ID for subscription checking
|
||||
include_domains: Only return results from these domains
|
||||
exclude_domains: Exclude results from these domains
|
||||
|
||||
Returns:
|
||||
List of source dicts with title, url, text, publishedDate, author, score keys
|
||||
|
||||
Raises:
|
||||
HTTPException(429): If user has exceeded subscription limits
|
||||
Exception: If Exa API key not configured or search fails
|
||||
"""
|
||||
# Preflight subscription check
|
||||
if user_id:
|
||||
from models.subscription_models import APIProvider
|
||||
from services.subscription import PricingService
|
||||
from services.database import get_session_for_user
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = get_session_for_user(user_id)
|
||||
if db:
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
can_proceed, message, usage_info = pricing_service.check_usage_limits(
|
||||
user_id=user_id,
|
||||
provider=APIProvider.EXA,
|
||||
tokens_requested=0,
|
||||
actual_provider_name="exa",
|
||||
)
|
||||
if not can_proceed:
|
||||
raise HTTPException(status_code=429, detail={
|
||||
'error': 'insufficient_balance',
|
||||
'message': message,
|
||||
'provider': 'exa',
|
||||
'usage_info': usage_info or {}
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[Exa simple_search] Preflight check failed: {e}")
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
search_kwargs = {
|
||||
"type": "auto",
|
||||
"num_results": num_results,
|
||||
"text": {"max_characters": 1000},
|
||||
"highlights": {"num_sentences": 2, "highlights_per_url": 2},
|
||||
}
|
||||
if include_domains:
|
||||
search_kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
search_kwargs["exclude_domains"] = exclude_domains
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.exa.search_and_contents(query, **search_kwargs),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Exa simple_search] API call failed: {e}")
|
||||
# Retry with simpler parameters
|
||||
retry_kwargs = {"type": "auto", "num_results": num_results, "text": True}
|
||||
if include_domains:
|
||||
retry_kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
retry_kwargs["exclude_domains"] = exclude_domains
|
||||
try:
|
||||
logger.info("[Exa simple_search] Retrying with simplified parameters")
|
||||
results = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.exa.search_and_contents(query, **retry_kwargs),
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.error(f"[Exa simple_search] Retry also failed: {retry_error}")
|
||||
raise RuntimeError(f"Exa search failed: {str(retry_error)}") from retry_error
|
||||
|
||||
sources = []
|
||||
for result in results.results:
|
||||
sources.append({
|
||||
'title': getattr(result, 'title', 'Untitled'),
|
||||
'url': getattr(result, 'url', ''),
|
||||
'text': getattr(result, 'text', ''),
|
||||
'publishedDate': getattr(result, 'publishedDate', ''),
|
||||
'author': getattr(result, 'author', ''),
|
||||
'score': (lambda v: v if v is not None else 0.5)(getattr(result, 'score', 0.5)),
|
||||
})
|
||||
|
||||
# Track usage
|
||||
if user_id:
|
||||
cost = 0.005 # ~0.5 cents per search
|
||||
try:
|
||||
self.track_usage(user_id, cost)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Exa simple_search] Failed to track usage: {e}")
|
||||
|
||||
logger.info(f"[Exa simple_search] Found {len(sources)} sources for query: {query[:80]}...")
|
||||
return sources
|
||||
|
||||
def track_usage(self, user_id: str, cost: float):
|
||||
"""Track Exa API usage after successful call."""
|
||||
from services.database import get_session_for_user
|
||||
from services.subscription import PricingService
|
||||
from sqlalchemy import text
|
||||
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
logger.warning(f"[track_usage] Could not get DB session for user {user_id}")
|
||||
return
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
current_period = pricing_service.get_current_billing_period(user_id)
|
||||
|
||||
# Update exa_calls and exa_cost via SQL UPDATE
|
||||
update_query = text("""
|
||||
UPDATE usage_summaries
|
||||
SET exa_calls = COALESCE(exa_calls, 0) + 1,
|
||||
exa_cost = COALESCE(exa_cost, 0) + :cost,
|
||||
total_calls = total_calls + 1,
|
||||
total_cost = total_cost + :cost
|
||||
WHERE user_id = :user_id AND billing_period = :period
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
'cost': cost,
|
||||
'user_id': user_id,
|
||||
'period': current_period
|
||||
})
|
||||
db.commit()
|
||||
|
||||
logger.info(f"[Exa] Tracked usage: user={user_id}, cost=${cost}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Exa] Failed to track usage: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_exa_content_provider: Optional[ExaContentResearchProvider] = None
|
||||
|
||||
|
||||
def get_exa_content_provider() -> ExaContentResearchProvider:
|
||||
"""Get or create the global Exa content research provider instance."""
|
||||
global _exa_content_provider
|
||||
if _exa_content_provider is None:
|
||||
_exa_content_provider = ExaContentResearchProvider()
|
||||
return _exa_content_provider
|
||||
@@ -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,105 +344,14 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
setDraft
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`linkedin-writer ${className}`}
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff' // White professional background
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
userPreferences={userPreferences}
|
||||
chatHistory={chatHistory}
|
||||
showPreferencesModal={showPreferencesModal}
|
||||
onPreferencesModalChange={setShowPreferencesModal}
|
||||
onPreferencesChange={handlePreferencesChange}
|
||||
onClearHistory={handleClearHistory}
|
||||
getHistoryLength={getHistoryLength}
|
||||
/>
|
||||
|
||||
{/* Lightweight progress tracker under header */}
|
||||
<div style={{
|
||||
padding: '6px 16px',
|
||||
transition: 'all 300ms ease',
|
||||
opacity: progressActive || progressSteps.length > 0 ? 1 : 0,
|
||||
transform: progressActive || progressSteps.length > 0 ? 'translateY(0)' : 'translateY(-10px)',
|
||||
height: progressActive || progressSteps.length > 0 ? 'auto' : 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<ProgressTracker steps={progressSteps as any} active={progressActive} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Debug: Enhanced Persistence Test Buttons (remove in production) */}
|
||||
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', backgroundColor: '#ffffff' }}>
|
||||
{/* Loading Indicator */}
|
||||
<LoadingIndicator
|
||||
isGenerating={isGenerating}
|
||||
loadingMessage={loadingMessage}
|
||||
currentAction={currentAction}
|
||||
/>
|
||||
|
||||
{/* Content Area */}
|
||||
{draft || isGenerating ? (<>
|
||||
{/* Editor Panel - Show when there's content or generating */}
|
||||
<ContentEditor
|
||||
isPreviewing={isPreviewing}
|
||||
pendingEdit={pendingEdit}
|
||||
livePreviewHtml={livePreviewHtml}
|
||||
draft={draft}
|
||||
showPreview={showPreview}
|
||||
isGenerating={isGenerating}
|
||||
loadingMessage={loadingMessage}
|
||||
// Grounding data
|
||||
researchSources={researchSources}
|
||||
citations={citations}
|
||||
qualityMetrics={qualityMetrics}
|
||||
groundingEnabled={groundingEnabled}
|
||||
searchQueries={searchQueries}
|
||||
onConfirmChanges={handleConfirmChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onDraftChange={handleDraftChange}
|
||||
onPreviewToggle={handlePreviewToggle}
|
||||
topic={context ? context.split('\n')[0].substring(0, 50) : undefined}
|
||||
/>
|
||||
|
||||
|
||||
</>) : (
|
||||
/* Welcome Message - Show when no content */
|
||||
<WelcomeMessage
|
||||
draft={draft}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Register CopilotKit Actions */}
|
||||
<RegisterLinkedInActions />
|
||||
<RegisterLinkedInEditActions />
|
||||
{/* Enhanced Persona-Aware Actions */}
|
||||
<RegisterLinkedInActionsEnhanced />
|
||||
|
||||
|
||||
{/* CopilotKit Sidebar */}
|
||||
<CopilotSidebar
|
||||
className="alwrity-copilot-sidebar linkedin-writer"
|
||||
labels={{
|
||||
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!`
|
||||
}}
|
||||
suggestions={getIntelligentSuggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
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();
|
||||
@@ -347,7 +361,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
||||
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})
|
||||
@@ -404,21 +417,143 @@ ALWAYS generate content that matches this persona's linguistic fingerprint and p
|
||||
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}`}
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff' // White professional background
|
||||
}}
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
userPreferences={userPreferences}
|
||||
chatHistory={chatHistory}
|
||||
showPreferencesModal={showPreferencesModal}
|
||||
onPreferencesModalChange={setShowPreferencesModal}
|
||||
onPreferencesChange={handlePreferencesChange}
|
||||
onClearHistory={handleClearHistory}
|
||||
getHistoryLength={getHistoryLength}
|
||||
/>
|
||||
|
||||
{/* Lightweight progress tracker under header */}
|
||||
<div style={{
|
||||
padding: '6px 16px',
|
||||
transition: 'all 300ms ease',
|
||||
opacity: progressActive || progressSteps.length > 0 ? 1 : 0,
|
||||
transform: progressActive || progressSteps.length > 0 ? 'translateY(0)' : 'translateY(-10px)',
|
||||
height: progressActive || progressSteps.length > 0 ? 'auto' : 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<ProgressTracker steps={progressSteps as ProgressStep[]} active={progressActive} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Debug: Enhanced Persistence Test Buttons (remove in production) */}
|
||||
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', backgroundColor: '#ffffff' }}>
|
||||
{/* Loading Indicator */}
|
||||
<LoadingIndicator
|
||||
isGenerating={isGenerating}
|
||||
loadingMessage={loadingMessage}
|
||||
currentAction={currentAction}
|
||||
/>
|
||||
|
||||
{/* Content Area */}
|
||||
{draft || isGenerating ? (<>
|
||||
{/* Editor Panel - Show when there's content or generating */}
|
||||
<ContentEditor
|
||||
isPreviewing={isPreviewing}
|
||||
pendingEdit={pendingEdit}
|
||||
livePreviewHtml={livePreviewHtml}
|
||||
draft={draft}
|
||||
showPreview={showPreview}
|
||||
isGenerating={isGenerating}
|
||||
loadingMessage={loadingMessage}
|
||||
// Grounding data
|
||||
researchSources={researchSources}
|
||||
citations={citations}
|
||||
qualityMetrics={qualityMetrics}
|
||||
groundingEnabled={groundingEnabled}
|
||||
searchQueries={searchQueries}
|
||||
onConfirmChanges={handleConfirmChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onDraftChange={handleDraftChange}
|
||||
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 />
|
||||
{/* Enhanced Persona-Aware Actions */}
|
||||
<RegisterLinkedInActionsEnhanced />
|
||||
|
||||
|
||||
{/* CopilotKit Sidebar */}
|
||||
<CopilotSidebar
|
||||
className="alwrity-copilot-sidebar linkedin-writer"
|
||||
labels={labels}
|
||||
suggestions={getIntelligentSuggestions}
|
||||
makeSystemMessage={makeSystemMessage}
|
||||
observabilityHooks={observabilityHooks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||
import {
|
||||
mapPostType,
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
readPrefs
|
||||
} from './utils/linkedInWriterUtils';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
const RegisterLinkedInActions: React.FC = () => {
|
||||
// LinkedIn Image Generation Actions
|
||||
@@ -53,7 +52,12 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', required: true, description: 'The image generation prompt' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style', attributes: [
|
||||
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||
{ name: 'style', type: 'string', required: true, description: 'Writing style/tone' }
|
||||
] },
|
||||
{ name: 'aspect_ratio', type: 'string', required: false, description: 'Image aspect ratio (default: 1:1)' }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
@@ -88,6 +92,54 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Image Editing Action
|
||||
useCopilotActionTyped({
|
||||
name: 'editLinkedInImage',
|
||||
description: 'Edit an existing LinkedIn image using natural language (change style, background, colors, etc.). Requires an image_id from a previously generated LinkedIn image.',
|
||||
parameters: [
|
||||
{ name: 'image_id', type: 'string', required: true, description: 'ID of the previously generated LinkedIn image to edit' },
|
||||
{ name: 'prompt', type: 'string', required: true, description: 'Natural language description of desired edits (e.g., "Make the background blue", "Add more professional look")' },
|
||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type', attributes: [
|
||||
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||
] },
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/linkedin/edit-image', {
|
||||
image_id: args.image_id,
|
||||
prompt: args.prompt,
|
||||
content_context: args.content_context,
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
image_data: result.image_data,
|
||||
image_id: result.image_id,
|
||||
image_url: result.image_url,
|
||||
message: result.image_id
|
||||
? `✅ LinkedIn image edited successfully! Your edited image (ID: ${result.image_id}) is ready to use.`
|
||||
: `✅ LinkedIn image edited successfully! The image is ready to use in your content.`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Image editing failed'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing image:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to edit image. Please try again.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Post Generation
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInPost',
|
||||
@@ -468,7 +520,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
parameters: [
|
||||
{ name: 'topic', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'slide_count', type: 'number', required: false }
|
||||
{ name: 'number_of_slides', type: 'number', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const prefs = readPrefs();
|
||||
@@ -499,7 +551,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
const res = await linkedInWriterApi.generateCarousel({
|
||||
topic: args?.topic || prefs.topic || 'Professional development tips',
|
||||
industry: mapIndustry(args?.industry || prefs.industry),
|
||||
slide_count: args?.slide_count || prefs.slide_count || 8,
|
||||
number_of_slides: args?.number_of_slides || prefs.number_of_slides || 8,
|
||||
tone: mapTone(args?.tone || prefs.tone),
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||
key_takeaways: args?.key_takeaways || prefs.key_takeaways || [],
|
||||
@@ -898,7 +950,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Profile Optimization
|
||||
// LinkedIn Profile Optimization (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeLinkedInProfile',
|
||||
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
||||
@@ -907,29 +959,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'experience_level', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.optimizeProfile({
|
||||
current_headline: args?.current_headline || 'Professional',
|
||||
industry: mapIndustry(args?.industry),
|
||||
experience_level: args?.experience_level || 'mid-level',
|
||||
target_role: args?.target_role,
|
||||
key_skills: args?.key_skills || []
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
let content = `# LinkedIn Profile Optimization\n\n`;
|
||||
content += `## Optimized Headline\n${res.data.headline}\n\n`;
|
||||
content += `## About Section\n${res.data.about}\n\n`;
|
||||
content += `## Key Skills\n${res.data.skills?.join(', ')}\n\n`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to optimize LinkedIn profile' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Profile Optimization is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Poll Generation
|
||||
// LinkedIn Poll Generation (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInPoll',
|
||||
description: 'Generate an engaging LinkedIn poll with professional questions',
|
||||
@@ -938,31 +974,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'industry', type: 'string', required: false },
|
||||
{ name: 'poll_type', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.generatePoll({
|
||||
topic: args?.topic || 'Professional development',
|
||||
industry: mapIndustry(args?.industry),
|
||||
poll_type: args?.poll_type || 'professional',
|
||||
target_audience: args?.target_audience || 'Industry professionals',
|
||||
question_count: args?.question_count || 1
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
let content = `# LinkedIn Poll: ${res.data.question}\n\n`;
|
||||
content += `## Options\n`;
|
||||
res.data.options?.forEach((option: string, index: number) => {
|
||||
content += `${index + 1}. ${option}\n`;
|
||||
});
|
||||
content += `\n## Context\n${res.data.context || ''}\n\n`;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to generate LinkedIn poll' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Poll Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn Company Update Generation
|
||||
// LinkedIn Company Update Generation (Coming Soon)
|
||||
useCopilotActionTyped({
|
||||
name: 'generateLinkedInCompanyUpdate',
|
||||
description: 'Generate a professional company update for LinkedIn',
|
||||
@@ -971,22 +989,9 @@ const RegisterLinkedInActions: React.FC = () => {
|
||||
{ name: 'update_type', type: 'string', required: false },
|
||||
{ name: 'industry', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
const res = await linkedInWriterApi.generateCompanyUpdate({
|
||||
company_name: args?.company_name || 'Your Company',
|
||||
update_type: args?.update_type || 'achievement',
|
||||
industry: mapIndustry(args?.industry),
|
||||
announcement: args?.announcement,
|
||||
target_audience: args?.target_audience || 'Industry professionals and clients',
|
||||
include_metrics: args?.include_metrics ?? true
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
const content = res.data.content;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: false, message: res.error || 'Failed to generate LinkedIn company update' };
|
||||
handler: async () => {
|
||||
showToastNotification('LinkedIn Company Update Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||
return { success: false, message: 'Feature coming soon' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||
import {
|
||||
mapPostType,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
readPrefs
|
||||
} from './utils/linkedInWriterUtils';
|
||||
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||
|
||||
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
// Get persona context for enhanced content generation
|
||||
@@ -102,9 +100,8 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
}
|
||||
|
||||
// Apply persona constraints to parameters
|
||||
const personaConstraints = platformPersona?.content_format_rules as any || {};
|
||||
const maxLength = personaConstraints.character_limit || prefs.max_length || 2000;
|
||||
const optimalLength = personaConstraints.optimal_length || '150-300 words';
|
||||
const maxLength = platformPersona?.content_format_rules?.character_limit || prefs.max_length || 2000;
|
||||
const optimalLength = platformPersona?.content_format_rules?.optimal_length || '150-300 words';
|
||||
|
||||
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
||||
|
||||
@@ -329,8 +326,10 @@ 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,
|
||||
@@ -373,7 +372,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
},
|
||||
platform_compliance: {
|
||||
character_count: content.length,
|
||||
optimal_range: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
||||
optimal_range: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||
status: 'analyzing',
|
||||
suggestions: [] as string[]
|
||||
}
|
||||
@@ -401,7 +400,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
});
|
||||
|
||||
// Platform compliance check
|
||||
const charLimit = (platformPersona.content_format_rules as any)?.character_limit || 3000;
|
||||
const charLimit = platformPersona.content_format_rules?.character_limit || 3000;
|
||||
if (content.length > charLimit) {
|
||||
validation.platform_compliance.status = 'exceeds_limit';
|
||||
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
||||
@@ -445,13 +444,13 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||
const suggestions = {
|
||||
writing_style: {
|
||||
sentence_structure: corePersona.linguistic_fingerprint?.sentence_metrics?.preferred_sentence_type || 'balanced',
|
||||
tone_recommendation: (corePersona as any).tonal_range?.default_tone || 'professional_friendly',
|
||||
tone_recommendation: platformPersona?.tonal_range?.default_tone || 'professional_friendly',
|
||||
vocabulary_level: corePersona.linguistic_fingerprint?.lexical_features?.vocabulary_level || 'professional'
|
||||
},
|
||||
platform_optimization: {
|
||||
character_limit: (platformPersona.content_format_rules as any)?.character_limit || 3000,
|
||||
optimal_length: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
||||
hashtag_strategy: (platformPersona.lexical_features as any)?.hashtag_strategy || '3-5 relevant hashtags'
|
||||
character_limit: platformPersona.content_format_rules?.character_limit || 3000,
|
||||
optimal_length: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||
hashtag_strategy: platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'
|
||||
},
|
||||
persona_specific: {
|
||||
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
||||
|
||||
@@ -1,156 +1,216 @@
|
||||
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';
|
||||
if (!content.trim()) return { success: false, message: 'No content to professionalize' };
|
||||
|
||||
// For now, return a placeholder response
|
||||
const professionalizedContent = `[Professionalized version of your content for ${industry} industry targeting ${targetAudience}]\n\n${content}`;
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'professionalize',
|
||||
industry: args?.industry,
|
||||
target_audience: args?.target_audience,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: professionalizedContent } }));
|
||||
return { success: true, content: professionalizedContent };
|
||||
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 || '';
|
||||
if (!content.trim()) return { success: false, message: 'No content to optimize' };
|
||||
|
||||
// Placeholder for hashtag addition
|
||||
const hashtags = '#ProfessionalDevelopment #Networking #IndustryInsights #CareerGrowth';
|
||||
const contentWithHashtags = `${content}\n\n${hashtags}`;
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'optimize_engagement',
|
||||
industry: args?.industry,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithHashtags } }));
|
||||
return { success: true, content: contentWithHashtags };
|
||||
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';
|
||||
if (!content.trim()) return { success: false, message: 'No content to adjust tone for' };
|
||||
|
||||
// Placeholder for tone adjustment
|
||||
const adjustedContent = `[Content adjusted to ${targetTone} tone]\n\n${content}`;
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'adjust_tone',
|
||||
tone: targetTone,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: adjustedContent } }));
|
||||
return { success: true, content: adjustedContent };
|
||||
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';
|
||||
if (!content.trim()) return { success: false, message: 'No content to expand' };
|
||||
|
||||
// Placeholder for content expansion
|
||||
const expandedContent = `${content}\n\n[Additional ${expansionType} and context added here]`;
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'expand',
|
||||
industry: args?.industry,
|
||||
target_audience: args?.target_audience,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: expandedContent } }));
|
||||
return { success: true, content: expandedContent };
|
||||
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';
|
||||
const targetLength = args?.target_length || 'medium';
|
||||
if (!content.trim()) return { success: false, message: 'No content to condense' };
|
||||
|
||||
// Placeholder for content condensation
|
||||
const condensedContent = `[Condensed to ${targetLength} format]\n\n${content.substring(0, Math.min(content.length, 500))}...`;
|
||||
const lengthMap: Record<string, string> = { short: 'very concise (1-2 sentences)', medium: 'half the original length', long: 'slightly shortened' };
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: condensedContent } }));
|
||||
return { success: true, content: condensedContent };
|
||||
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';
|
||||
if (!content.trim()) return { success: false, message: 'No content to add CTA to' };
|
||||
|
||||
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!'
|
||||
};
|
||||
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 cta = ctaOptions[ctaType as keyof typeof ctaOptions] || ctaOptions.engagement;
|
||||
const contentWithCTA = `${content}\n\n${cta}`;
|
||||
const res = await linkedInWriterApi.editContent({
|
||||
content,
|
||||
edit_type: 'add_cta',
|
||||
parameters: { cta_type: args?.cta_type || 'engagement' },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithCTA } }));
|
||||
return { success: true, content: contentWithCTA };
|
||||
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' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import '../../../types/linkedinWriterEvents';
|
||||
|
||||
// Define the cache data type
|
||||
interface BrainstormCacheData {
|
||||
@@ -118,7 +119,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
const handler = async (ev: any) => {
|
||||
try {
|
||||
// Store the event for refresh functionality
|
||||
(window as any).lastBrainstormEvent = ev;
|
||||
window.lastBrainstormEvent = ev;
|
||||
|
||||
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
||||
const finalSeed = ideaSeed || prompt;
|
||||
@@ -239,8 +240,8 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
setBrainstormVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
||||
|
||||
return (
|
||||
@@ -275,7 +276,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||
<button
|
||||
onClick={() => {
|
||||
// Force refresh by clearing cache and re-running
|
||||
const { prompt, seed: ideaSeed } = (window as any).lastBrainstormEvent?.detail || {};
|
||||
const { prompt, seed: ideaSeed } = window.lastBrainstormEvent?.detail || {};
|
||||
if (prompt || ideaSeed) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
||||
|
||||
@@ -23,7 +23,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
target_audience: args.target_audience ?? prefs.target_audience ?? '',
|
||||
tone: args.tone ?? prefs.tone ?? 'professional',
|
||||
industry: args.industry ?? prefs.industry ?? 'technology',
|
||||
slide_count: args.slide_count ?? (prefs.slide_count ?? 5),
|
||||
number_of_slides: args.number_of_slides ?? (prefs.number_of_slides ?? 5),
|
||||
key_takeaways: args.key_takeaways ?? (prefs.key_takeaways ?? []),
|
||||
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||
include_cta_slide: args.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||
@@ -40,7 +40,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: {
|
||||
action: 'Generating LinkedIn Carousel',
|
||||
message: `Creating a ${form.slide_count}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
||||
message: `Creating a ${form.number_of_slides}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -59,7 +59,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
target_audience: form.target_audience,
|
||||
tone: mapTone(form.tone),
|
||||
industry: mapIndustry(form.industry),
|
||||
slide_count: form.slide_count,
|
||||
number_of_slides: form.number_of_slides,
|
||||
key_takeaways: form.key_takeaways,
|
||||
include_cover_slide: form.include_cover_slide,
|
||||
include_cta_slide: form.include_cta_slide,
|
||||
@@ -73,7 +73,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
tone: form.tone,
|
||||
industry: form.industry,
|
||||
target_audience: form.target_audience,
|
||||
slide_count: form.slide_count,
|
||||
number_of_slides: form.number_of_slides,
|
||||
key_takeaways: form.key_takeaways,
|
||||
include_cover_slide: form.include_cover_slide,
|
||||
include_cta_slide: form.include_cta_slide,
|
||||
@@ -100,7 +100,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
success: true,
|
||||
carousel_content: content,
|
||||
title: res.data.title,
|
||||
slide_count: res.data.slides.length
|
||||
number_of_slides: res.data.slides.length
|
||||
});
|
||||
} else {
|
||||
throw new Error('No data received from API');
|
||||
@@ -183,11 +183,11 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="slide_count">Number of Slides</label>
|
||||
<label htmlFor="number_of_slides">Number of Slides</label>
|
||||
<select
|
||||
id="slide_count"
|
||||
value={form.slide_count}
|
||||
onChange={(e) => setForm({ ...form, slide_count: parseInt(e.target.value) })}
|
||||
id="number_of_slides"
|
||||
value={form.number_of_slides}
|
||||
onChange={(e) => setForm({ ...form, number_of_slides: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={3}>3 slides (Quick overview)</option>
|
||||
<option value={5}>5 slides (Standard)</option>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
||||
import { useCopilotContext } from '@copilotkit/react-core';
|
||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import { showToastNotification } from '../../../utils/toastNotifications';
|
||||
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||
import '../../../types/linkedinWriterEvents';
|
||||
|
||||
// Optional debug flag: set to true to enable verbose logs locally
|
||||
const DEBUG_LINKEDIN = false;
|
||||
@@ -66,9 +67,9 @@ export const useCopilotActions = ({
|
||||
if (copilotContext && typeof copilotContext === 'object') {
|
||||
try {
|
||||
// Check if context has any message sending capabilities
|
||||
if ('sendMessage' in copilotContext && typeof copilotContext.sendMessage === 'function') {
|
||||
if ('sendMessage' in copilotContext && typeof (copilotContext as Record<string, unknown>).sendMessage === 'function') {
|
||||
setTimeout(() => {
|
||||
(copilotContext as any).sendMessage(prompt);
|
||||
(copilotContext as { sendMessage: (msg: string) => void }).sendMessage(prompt);
|
||||
console.log('Message sent via context');
|
||||
return;
|
||||
}, 500);
|
||||
@@ -85,7 +86,7 @@ export const useCopilotActions = ({
|
||||
document.querySelector('button[title*="generateFromPrompt"]');
|
||||
if (actionButton) {
|
||||
// Set the prompt in a temporary storage for the action to pick up
|
||||
(window as any).tempPromptForGeneration = prompt;
|
||||
window.tempPromptForGeneration = prompt;
|
||||
(actionButton as HTMLElement).click();
|
||||
console.log('Triggered generateFromPrompt action with:', prompt);
|
||||
return;
|
||||
@@ -235,8 +236,8 @@ export const useCopilotActions = ({
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||
}, []);
|
||||
|
||||
// Allow external prompts to trigger content generation
|
||||
@@ -248,15 +249,15 @@ export const useCopilotActions = ({
|
||||
],
|
||||
handler: async ({ prompt }: { prompt: string }) => {
|
||||
// Check for temporary prompt from brainstorm flow
|
||||
const finalPrompt = prompt || (window as any).tempPromptForGeneration;
|
||||
const finalPrompt = prompt || window.tempPromptForGeneration;
|
||||
|
||||
if (!finalPrompt) {
|
||||
return { success: false, message: 'No prompt provided' };
|
||||
}
|
||||
|
||||
// Clear the temporary prompt
|
||||
if ((window as any).tempPromptForGeneration) {
|
||||
delete (window as any).tempPromptForGeneration;
|
||||
if (window.tempPromptForGeneration) {
|
||||
delete window.tempPromptForGeneration;
|
||||
}
|
||||
|
||||
// Set the prompt as context and trigger generation
|
||||
@@ -281,9 +282,21 @@ export const useCopilotActions = ({
|
||||
name: 'editLinkedInDraft',
|
||||
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen'] }
|
||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen', 'AddEmojis', 'AddHashtags', 'ImproveClarity', 'AdjustTone', 'RewriteHook'] }
|
||||
],
|
||||
handler: async ({ operation }: { operation: string }) => {
|
||||
const COMING_SOON_OPS = ['ImproveClarity', 'AdjustTone', 'RewriteHook'];
|
||||
if (COMING_SOON_OPS.includes(operation)) {
|
||||
const labels: Record<string, string> = {
|
||||
ImproveClarity: 'Improve Clarity',
|
||||
AdjustTone: 'Tone Adjustment',
|
||||
RewriteHook: 'Hook Rewrite'
|
||||
};
|
||||
const label = labels[operation] || operation;
|
||||
showToastNotification(`${label} is coming soon! This feature will use AI to enhance your content.`, 'info');
|
||||
return { success: false, message: `${label} feature coming soon` };
|
||||
}
|
||||
|
||||
const currentDraft = draft || '';
|
||||
if (!currentDraft) {
|
||||
return { success: false, message: 'No draft content to edit' };
|
||||
@@ -336,6 +349,49 @@ export const useCopilotActions = ({
|
||||
}
|
||||
break;
|
||||
|
||||
case 'AddEmojis': {
|
||||
const selectEmojis = (text: string): string[] => {
|
||||
const lower = text.toLowerCase();
|
||||
let category = 'general';
|
||||
if (/achiev|success|milestone|goal|win/i.test(lower)) category = 'achievement';
|
||||
else if (/strateg|plan|growth|metric/i.test(lower)) category = 'strategy';
|
||||
else if (/collabor|team|partner|connect|network/i.test(lower)) category = 'collaboration';
|
||||
else if (/learn|skill|develop|train|educat/i.test(lower)) category = 'learning';
|
||||
else if (/innovat|new|future|transform|ai|tech/i.test(lower)) category = 'innovation';
|
||||
const emojiSets: Record<string, string[]> = {
|
||||
achievement: ['🏆', '🎯', '⭐', '🚀', '💪'],
|
||||
strategy: ['📈', '📊', '🧭', '💡', '🔑'],
|
||||
collaboration: ['🤝', '👥', '💬', '🌐', '🤝'],
|
||||
learning: ['📚', '🎓', '🧠', '💡', '📖'],
|
||||
innovation: ['💡', '🔬', '⚡', '🔮', '✨'],
|
||||
general: ['✅', '🎯', '💡', '📌', '🔥']
|
||||
};
|
||||
return emojiSets[category];
|
||||
};
|
||||
const emojis = selectEmojis(currentDraft);
|
||||
const enriched = currentDraft.split('\n').map((line: string, i: number) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('http')) return line;
|
||||
return `${emojis[i % emojis.length]} ${line}`;
|
||||
}).join('\n');
|
||||
editedContent = enriched;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AddHashtags': {
|
||||
const INDUSTRY_TAGS: Record<string, string[]> = {
|
||||
technology: ['#TechLeadership', '#DigitalTransformation', '#Innovation', '#FutureOfWork', '#AI'],
|
||||
marketing: ['#MarketingStrategy', '#DigitalMarketing', '#ContentMarketing', '#GrowthMarketing', '#BrandBuilding'],
|
||||
default: ['#ProfessionalDevelopment', '#CareerGrowth', '#Leadership', '#IndustryInsights', '#Networking']
|
||||
};
|
||||
const existing: string[] = currentDraft.match(/#[A-Za-z0-9_]+/g) || [];
|
||||
if (existing.length >= 5) break;
|
||||
const tags = (INDUSTRY_TAGS[userPreferences?.industry?.toLowerCase()] || INDUSTRY_TAGS.default)
|
||||
.filter((t: string) => !existing.includes(t)).slice(0, 5);
|
||||
if (tags.length > 0) editedContent = `${currentDraft}\n\n${tags.join(' ')}`;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, message: 'Unknown operation' };
|
||||
}
|
||||
@@ -355,34 +411,57 @@ export const useCopilotActions = ({
|
||||
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
|
||||
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
||||
const isLong = (draft || '').length > 500;
|
||||
const hasPersona = !!(corePersona && platformPersona);
|
||||
|
||||
// Debug logging for suggestions
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
||||
hasContent,
|
||||
justGeneratedContent,
|
||||
hasPersona,
|
||||
draftLength: draft?.length || 0
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
// Initial suggestions for content creation
|
||||
const initialSuggestions = [
|
||||
const initialSuggestions: { title: string; message: string }[] = [];
|
||||
|
||||
// Persona-aware actions first when persona data is available
|
||||
if (hasPersona) {
|
||||
initialSuggestions.push(
|
||||
{ title: '🎭 Post (Persona-Optimized)', message: 'Use tool generateLinkedInPostWithPersona to create a post optimized for your writing style and LinkedIn platform constraints.' },
|
||||
{ title: '🎭 Article (Persona-Optimized)', message: 'Use tool generateLinkedInArticleWithPersona to write an article with persona-aware optimization.' }
|
||||
);
|
||||
}
|
||||
|
||||
// Standard actions
|
||||
initialSuggestions.push(
|
||||
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
||||
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
||||
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
||||
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
||||
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' },
|
||||
{ title: '🎨 Visual Content', message: 'Create engaging visual content with AI-generated images optimized for LinkedIn.' }
|
||||
];
|
||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' }
|
||||
);
|
||||
|
||||
// Persona validation and suggestions when persona is available
|
||||
if (hasPersona) {
|
||||
initialSuggestions.push(
|
||||
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check existing content against your writing persona.' },
|
||||
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations based on your persona.' }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
||||
return initialSuggestions;
|
||||
} else {
|
||||
// Refinement suggestions for existing content - use direct edit actions
|
||||
const refinementSuggestions = [
|
||||
const refinementSuggestions: { title: string; message: string }[] = [
|
||||
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
||||
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
||||
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
||||
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
||||
{ title: '😀 Add emojis', message: 'Use tool editLinkedInDraft with operation AddEmojis' },
|
||||
{ title: '🏷️ Add hashtags', message: 'Use tool editLinkedInDraft with operation AddHashtags' },
|
||||
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
||||
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
||||
];
|
||||
@@ -413,6 +492,14 @@ export const useCopilotActions = ({
|
||||
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
||||
}
|
||||
|
||||
// Persona-aware refinement actions
|
||||
if (hasPersona) {
|
||||
refinementSuggestions.push(
|
||||
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check this content against your writing persona.' },
|
||||
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations.' }
|
||||
);
|
||||
}
|
||||
|
||||
// Add image generation suggestion when there's content
|
||||
if (draft && draft.trim().length > 0) {
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
||||
@@ -439,7 +526,7 @@ export const useCopilotActions = ({
|
||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
||||
return refinementSuggestions;
|
||||
}
|
||||
}, [draft, justGeneratedContent]);
|
||||
}, [draft, justGeneratedContent, corePersona, platformPersona]);
|
||||
|
||||
// Return the suggestions function directly
|
||||
return getIntelligentSuggestions;
|
||||
|
||||
@@ -657,7 +657,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!useGoogleSearch || words.length < 4) return;
|
||||
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
||||
const tone = (corePersona as any)?.tonal_range?.default_tone || (platformPersona as any)?.tonal_range?.default_tone || 'professional';
|
||||
const tone = platformPersona?.tonal_range?.default_tone || 'professional';
|
||||
const goTo = corePersona?.linguistic_fingerprint?.lexical_features?.go_to_words?.slice(0,5)?.join(', ');
|
||||
const platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
||||
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import {
|
||||
AutoAwesome as SparklesIcon,
|
||||
PhotoCamera as PhotoIcon,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as ExclamationTriangleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||
|
||||
interface ImageGenerationSuggestionsProps {
|
||||
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
||||
@@ -51,9 +51,6 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
||||
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
||||
const [showPrompts, setShowPrompts] = useState(false);
|
||||
|
||||
// Use the same pattern as other components in the project
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// Register Copilot action for generating image prompts
|
||||
useCopilotActionTyped({
|
||||
name: 'generate_image_prompts',
|
||||
@@ -119,7 +116,12 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', required: true },
|
||||
{ name: 'content_context', type: 'object', required: true },
|
||||
{ name: 'content_context', type: 'object', required: true, attributes: [
|
||||
{ name: 'topic', type: 'string', required: true },
|
||||
{ name: 'industry', type: 'string', required: true },
|
||||
{ name: 'content_type', type: 'string', required: true },
|
||||
{ name: 'style', type: 'string', required: true }
|
||||
] },
|
||||
{ name: 'aspect_ratio', type: 'string', required: false }
|
||||
],
|
||||
handler: async (args: any) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
||||
|
||||
interface ProgressStep {
|
||||
export interface ProgressStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ProgressStatus;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||
|
||||
interface QuickCreateProps {
|
||||
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
userPreferences: LinkedInPreferences;
|
||||
}
|
||||
|
||||
type ContentType = 'post' | 'article' | 'carousel' | 'video_script';
|
||||
|
||||
const CONTENT_TYPES: { type: ContentType; label: string; icon: string; description: string; color: string }[] = [
|
||||
{ type: 'post', label: 'Post', icon: '📝', description: 'Professional LinkedIn post with engagement hooks', color: '#0a66c2' },
|
||||
{ type: 'article', label: 'Article', icon: '📄', description: 'Thought leadership article with in-depth analysis', color: '#057642' },
|
||||
{ type: 'carousel', label: 'Carousel', icon: '🎠', description: 'Multi-slide carousel for visual storytelling', color: '#8b5cf6' },
|
||||
{ type: 'video_script', label: 'Video Script', icon: '🎬', description: 'Engaging video script with hook & scenes', color: '#dc2626' }
|
||||
];
|
||||
|
||||
const TONES = ['Professional', 'Conversational', 'Authoritative', 'Inspirational', 'Educational', 'Friendly'];
|
||||
const INDUSTRIES = ['Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing', 'Retail', 'Marketing', 'Consulting', 'Real Estate', 'Legal', 'Non-profit', 'Entertainment', 'Energy', 'Custom'];
|
||||
|
||||
const defaultForm = {
|
||||
topic: '',
|
||||
industry: '',
|
||||
tone: '',
|
||||
target_audience: '',
|
||||
key_points: '',
|
||||
post_type: '',
|
||||
word_count: 1500,
|
||||
number_of_slides: 8,
|
||||
video_length: 60,
|
||||
key_takeaways: '',
|
||||
key_messages: '',
|
||||
key_sections: ''
|
||||
};
|
||||
|
||||
export const QuickCreate: React.FC<QuickCreateProps> = ({
|
||||
onGeneratePost,
|
||||
onGenerateArticle,
|
||||
onGenerateCarousel,
|
||||
onGenerateVideoScript,
|
||||
userPreferences
|
||||
}) => {
|
||||
const [selectedType, setSelectedType] = useState<ContentType | null>(null);
|
||||
const [formData, setFormData] = useState(defaultForm);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const openModal = (type: ContentType) => {
|
||||
setFormData({
|
||||
...defaultForm,
|
||||
industry: userPreferences?.industry || '',
|
||||
tone: userPreferences?.tone || 'Professional',
|
||||
target_audience: userPreferences?.target_audience || ''
|
||||
});
|
||||
setSelectedType(type);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setSelectedType(null);
|
||||
setFormData(defaultForm);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedType || generating) return;
|
||||
setGenerating(true);
|
||||
const params = { ...formData };
|
||||
try {
|
||||
const generators = {
|
||||
post: onGeneratePost,
|
||||
article: onGenerateArticle,
|
||||
carousel: onGenerateCarousel,
|
||||
video_script: onGenerateVideoScript
|
||||
};
|
||||
await generators[selectedType](params);
|
||||
closeModal();
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setField = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const modalContent = useMemo(() => {
|
||||
if (!selectedType) return null;
|
||||
|
||||
const commonFields = (
|
||||
<>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Topic *</label>
|
||||
<input
|
||||
value={formData.topic}
|
||||
onChange={e => setField('topic', e.target.value)}
|
||||
placeholder={`e.g., ${selectedType === 'video_script' ? 'Networking tips' : 'AI trends in ' + (formData.industry || 'Technology')}`}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Industry</label>
|
||||
<select
|
||||
value={formData.industry}
|
||||
onChange={e => setField('industry', e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||
>
|
||||
{INDUSTRIES.map(ind => <option key={ind} value={ind}>{ind}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Tone</label>
|
||||
<select
|
||||
value={formData.tone}
|
||||
onChange={e => setField('tone', e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||
>
|
||||
{TONES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Target Audience</label>
|
||||
<input
|
||||
value={formData.target_audience}
|
||||
onChange={e => setField('target_audience', e.target.value)}
|
||||
placeholder="e.g., Product Managers, CTOs"
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (selectedType) {
|
||||
case 'post':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Post</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Points</label>
|
||||
<textarea
|
||||
value={formData.key_points}
|
||||
onChange={e => setField('key_points', e.target.value)}
|
||||
placeholder="Key point 1 / Key point 2 / Key point 3"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'article':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Article</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Word Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.word_count}
|
||||
onChange={e => setField('word_count', parseInt(e.target.value) || 1500)}
|
||||
min={500}
|
||||
max={5000}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Sections</label>
|
||||
<textarea
|
||||
value={formData.key_sections}
|
||||
onChange={e => setField('key_sections', e.target.value)}
|
||||
placeholder="Introduction / Current challenges / Best practices / Future outlook"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'carousel':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Carousel</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Number of Slides</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.number_of_slides}
|
||||
onChange={e => setField('number_of_slides', parseInt(e.target.value) || 8)}
|
||||
min={3}
|
||||
max={20}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Takeaways</label>
|
||||
<textarea
|
||||
value={formData.key_takeaways}
|
||||
onChange={e => setField('key_takeaways', e.target.value)}
|
||||
placeholder="Key insight / Important trend / Actionable tip"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'video_script':
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Video Script</h3>
|
||||
{commonFields}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Video Length (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.video_length}
|
||||
onChange={e => setField('video_length', parseInt(e.target.value) || 60)}
|
||||
min={15}
|
||||
max={600}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Messages</label>
|
||||
<textarea
|
||||
value={formData.key_messages}
|
||||
onChange={e => setField('key_messages', e.target.value)}
|
||||
placeholder="Core message / Practical advice / Call to action"
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [selectedType, formData]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', marginTop: 8 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 12, textAlign: 'center' }}>Quick Create</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
|
||||
{CONTENT_TYPES.map(({ type, label, icon, description, color }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => openModal(type)}
|
||||
style={{
|
||||
padding: '14px 10px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 12,
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)'
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${color}20`;
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.06)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, marginBottom: 6 }}>{icon}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#111827' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 4, lineHeight: '1.3' }}>{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generation Modal */}
|
||||
{selectedType && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10020, padding: 20 }}>
|
||||
<div style={{ background: 'white', width: 520, maxWidth: '100%', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #e5e7eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 15, color: '#111827' }}>{CONTENT_TYPES.find(c => c.type === selectedType)?.icon} {CONTENT_TYPES.find(c => c.type === selectedType)?.label}</div>
|
||||
<button onClick={closeModal} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#6b7280', padding: '4px 8px', borderRadius: 6 }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: 16, maxHeight: '60vh', overflow: 'auto' }}>
|
||||
{modalContent}
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeModal} style={{ padding: '10px 20px', border: '1px solid #d1d5db', borderRadius: 8, background: 'white', cursor: 'pointer', fontSize: 14, fontWeight: 600 }}>Cancel</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !formData.topic.trim()}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
background: generating ? '#9ca3af' : CONTENT_TYPES.find(c => c.type === selectedType)?.color || '#0a66c2',
|
||||
color: 'white',
|
||||
cursor: generating || !formData.topic.trim() ? 'not-allowed' : 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
opacity: generating || !formData.topic.trim() ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{generating && <div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid white', borderTopColor: 'transparent', animation: 'spin 0.8s linear infinite' }} />}
|
||||
{generating ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FeatureCarousel } from './FeatureCarousel';
|
||||
import { InfoModals } from './InfoModals';
|
||||
import { QuickCreate } from './QuickCreate';
|
||||
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||
|
||||
interface WelcomeMessageProps {
|
||||
draft: string;
|
||||
isGenerating: boolean;
|
||||
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||
userPreferences: LinkedInPreferences;
|
||||
}
|
||||
|
||||
export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||
draft,
|
||||
isGenerating
|
||||
isGenerating,
|
||||
onGeneratePost,
|
||||
onGenerateArticle,
|
||||
onGenerateCarousel,
|
||||
onGenerateVideoScript,
|
||||
userPreferences
|
||||
}) => {
|
||||
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
||||
const [showAssistiveModal, setShowAssistiveModal] = useState(false);
|
||||
@@ -267,6 +279,17 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||
Choose your preferred AI assistance mode to get started with content creation.
|
||||
</p>
|
||||
|
||||
{/* Quick Create - Direct generation buttons */}
|
||||
<div style={{ width: '100%', maxWidth: 640, marginBottom: 24 }}>
|
||||
<QuickCreate
|
||||
onGeneratePost={onGeneratePost}
|
||||
onGenerateArticle={onGenerateArticle}
|
||||
onGenerateCarousel={onGenerateCarousel}
|
||||
onGenerateVideoScript={onGenerateVideoScript}
|
||||
userPreferences={userPreferences}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Modals */}
|
||||
<InfoModals
|
||||
showCopilotModal={showCopilotModal}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export { default as PostHITL } from './PostHITL';
|
||||
export { default as ArticleHITL } from './ArticleHITL';
|
||||
export { default as CarouselHITL } from './CarouselHITL';
|
||||
export { default as VideoScriptHITL } from './VideoScriptHITL';
|
||||
export { default as CommentResponseHITL } from './CommentResponseHITL';
|
||||
|
||||
// New refactored components
|
||||
export { Header } from './Header';
|
||||
export { ContentEditor } from './ContentEditor';
|
||||
@@ -12,6 +6,7 @@ export { WelcomeMessage } from './WelcomeMessage';
|
||||
export { FeatureCarousel } from './FeatureCarousel';
|
||||
export { InfoModals } from './InfoModals';
|
||||
export { ProgressTracker } from './ProgressTracker';
|
||||
export type { ProgressStep } from './ProgressTracker';
|
||||
export { ContentRecommendations } from './ContentRecommendations';
|
||||
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
||||
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
||||
@@ -27,3 +22,4 @@ export { default as ImageGenerationTest } from './ImageGenerationTest';
|
||||
// Refactored Components
|
||||
export { default as BrainstormFlow } from './BrainstormFlow';
|
||||
export { useCopilotActions } from './CopilotActions';
|
||||
export { QuickCreate } from './QuickCreate';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useCopilotReadable } from '@copilotkit/react-core';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
loadHistory,
|
||||
clearHistory,
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
type ChatMsg,
|
||||
type LinkedInPreferences
|
||||
} from '../utils/storageUtils';
|
||||
import { getContextAwareSuggestions } from '../utils/linkedInWriterUtils';
|
||||
import { getContextAwareSuggestions, mapPostType, mapTone, mapIndustry, mapSearchEngine, readPrefs } from '../utils/linkedInWriterUtils';
|
||||
import { linkedInWriterApi, GroundingLevel } from '../../../services/linkedInWriterApi';
|
||||
|
||||
export function useLinkedInWriter() {
|
||||
// Core state
|
||||
@@ -51,24 +51,18 @@ export function useLinkedInWriter() {
|
||||
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
||||
|
||||
// UI state
|
||||
const [currentSuggestions, setCurrentSuggestions] = useState<Array<{title: string, message: string, priority?: string}>>([]);
|
||||
const currentSuggestions = useMemo(() => getContextAwareSuggestions(
|
||||
userPreferences,
|
||||
draft,
|
||||
chatHistory.slice(-5),
|
||||
userPreferences.last_used_actions || []
|
||||
), [userPreferences, draft, chatHistory]);
|
||||
const [showContextPanel, setShowContextPanel] = useState(false);
|
||||
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
||||
const [showContextModal, setShowContextModal] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [justGeneratedContent, setJustGeneratedContent] = useState(false);
|
||||
|
||||
// Update suggestions when context changes
|
||||
const updateSuggestions = useCallback(() => {
|
||||
const newSuggestions = getContextAwareSuggestions(
|
||||
userPreferences,
|
||||
draft,
|
||||
chatHistory.slice(-5),
|
||||
userPreferences.last_used_actions || []
|
||||
);
|
||||
setCurrentSuggestions(newSuggestions);
|
||||
}, [userPreferences, draft, chatHistory]);
|
||||
|
||||
// Track action usage and update preferences
|
||||
const trackActionUsage = useCallback((actionName: string) => {
|
||||
const currentPrefs = getPreferences();
|
||||
@@ -82,10 +76,278 @@ export function useLinkedInWriter() {
|
||||
// Reset the flag after 30 seconds
|
||||
setTimeout(() => setJustGeneratedContent(false), 30000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update suggestions after action usage
|
||||
setTimeout(() => updateSuggestions(), 100);
|
||||
}, [updateSuggestions]);
|
||||
// ── Direct generation methods (UI-driven, no CopilotKit dependency) ──────────
|
||||
const generatePost = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInPost', message: 'Generating LinkedIn post...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating content' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generatePost({
|
||||
topic: params?.topic || prefs.topic || 'AI transformation in business',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
post_type: mapPostType(params?.post_type || prefs.post_type),
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Business leaders and professionals',
|
||||
key_points: params?.key_points || prefs.key_points || [],
|
||||
include_hashtags: params?.include_hashtags ?? (prefs.include_hashtags ?? true),
|
||||
include_call_to_action: params?.include_call_to_action ?? (prefs.include_call_to_action ?? true),
|
||||
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||
max_length: params?.max_length || prefs.max_length || 2000,
|
||||
grounding_level: 'enhanced' as GroundingLevel,
|
||||
include_citations: true
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Content generated' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
const content = res.data.content;
|
||||
const hashtags = res.data.hashtags?.map((h: any) => h.hashtag).join(' ') || '';
|
||||
const cta = res.data.call_to_action || '';
|
||||
let fullContent = content;
|
||||
if (hashtags) fullContent += `\n\n${hashtags}`;
|
||||
if (cta) fullContent += `\n\n${cta}`;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateGroundingData', { detail: {
|
||||
researchSources: res.research_sources || [],
|
||||
citations: res.data?.citations || [],
|
||||
qualityMetrics: res.data?.quality_metrics || null,
|
||||
groundingEnabled: res.data?.grounding_enabled || false,
|
||||
searchQueries: res.data?.search_queries || []
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: fullContent }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Content finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInPost');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateArticle = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInArticle', message: 'Generating LinkedIn article...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating article content' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateArticle({
|
||||
topic: params?.topic || prefs.topic || 'Digital transformation strategies',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Industry professionals and executives',
|
||||
key_sections: params?.key_sections || prefs.key_sections || [],
|
||||
include_images: params?.include_images ?? (prefs.include_images ?? true),
|
||||
seo_optimization: params?.seo_optimization ?? (prefs.seo_optimization ?? true),
|
||||
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||
word_count: params?.word_count || prefs.word_count || 1500,
|
||||
grounding_level: 'enhanced' as GroundingLevel,
|
||||
include_citations: true
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Article content generated' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
const content = `# ${res.data.title}\n\n${res.data.content}`;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateGroundingData', { detail: {
|
||||
researchSources: res.research_sources || [],
|
||||
citations: res.data?.citations || [],
|
||||
qualityMetrics: res.data?.quality_metrics || null,
|
||||
groundingEnabled: res.data?.grounding_enabled || false,
|
||||
searchQueries: res.data?.search_queries || []
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Article finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInArticle');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateCarousel = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInCarousel', message: 'Generating LinkedIn carousel...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating carousel slides' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateCarousel({
|
||||
topic: params?.topic || prefs.topic || 'Professional development tips',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
number_of_slides: params?.number_of_slides || prefs.number_of_slides || 8,
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||
key_takeaways: params?.key_takeaways || prefs.key_takeaways || [],
|
||||
include_cover_slide: params?.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||
include_cta_slide: params?.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||
visual_style: params?.visual_style || prefs.visual_style || 'modern'
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated ${res.data.slides?.length || 0} slides` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
let content = `# ${res.data.title}\n\n`;
|
||||
res.data.slides.forEach((slide: any, index: number) => {
|
||||
content += `## Slide ${index + 1}: ${slide.title}\n\n${slide.content}\n\n`;
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Carousel finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInCarousel');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateVideoScript = useCallback(async (params?: any) => {
|
||||
const prefs = readPrefs();
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||
detail: { action: 'generateLinkedInVideoScript', message: 'Generating LinkedIn video script...' }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||
steps: [
|
||||
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||
{ id: 'research', label: 'Conducting research & analysis' },
|
||||
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||
{ id: 'content_generation', label: 'Generating video script' },
|
||||
{ id: 'citations', label: 'Extracting citations' },
|
||||
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||
]
|
||||
}}));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||
}));
|
||||
try {
|
||||
const res = await linkedInWriterApi.generateVideoScript({
|
||||
topic: params?.topic || prefs.topic || 'Professional networking tips',
|
||||
industry: mapIndustry(params?.industry || prefs.industry),
|
||||
video_length: params?.video_length || prefs.video_length || 60,
|
||||
tone: mapTone(params?.tone || prefs.tone),
|
||||
target_audience: params?.target_audience || prefs.target_audience || 'Professional networkers',
|
||||
key_messages: params?.key_messages || prefs.key_messages || [],
|
||||
include_hook: params?.include_hook ?? (prefs.include_hook ?? true),
|
||||
include_captions: params?.include_captions ?? (prefs.include_captions ?? true)
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated script with ${res.data.main_content?.length || 0} scenes` } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||
let content = `# Video Script: ${params?.topic || 'Professional Content'}\n\n`;
|
||||
content += `## Hook\n${res.data.hook}\n\n`;
|
||||
content += `## Main Content\n`;
|
||||
res.data.main_content.forEach((scene: any, index: number) => {
|
||||
content += `### Scene ${index + 1} (${scene.duration || '30s'})\n${scene.content}\n\n`;
|
||||
});
|
||||
content += `## Conclusion\n${res.data.conclusion}\n\n`;
|
||||
content += `## Video Description\n${res.data.video_description}\n\n`;
|
||||
if (res.data.captions) {
|
||||
content += `## Captions\n${res.data.captions.join('\n')}\n\n`;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Video script finalized' } }));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
trackActionUsage('generateLinkedInVideoScript');
|
||||
return { success: true, data: res.data };
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||
return { success: false, error: res.error || 'Generation failed' };
|
||||
} catch (error: any) {
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||
return { success: false, error: error.message || 'Generation failed' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize chat history and preferences from localStorage
|
||||
useEffect(() => {
|
||||
@@ -229,11 +491,6 @@ export function useLinkedInWriter() {
|
||||
}
|
||||
}, [context]);
|
||||
|
||||
// Update suggestions when relevant state changes
|
||||
useEffect(() => {
|
||||
updateSuggestions();
|
||||
}, [updateSuggestions]);
|
||||
|
||||
// Handle draft updates from CopilotKit actions
|
||||
useEffect(() => {
|
||||
const handleUpdateDraft = (event: CustomEvent) => {
|
||||
@@ -246,9 +503,7 @@ export function useLinkedInWriter() {
|
||||
setCurrentAction(null);
|
||||
// Auto-show preview when new content is generated
|
||||
setShowPreview(true);
|
||||
// Hide progress tracker when content is generated
|
||||
setProgressActive(false);
|
||||
setProgressSteps([]);
|
||||
// Progress is finalized by the progressStep/progressComplete events dispatched after this
|
||||
console.log('[LinkedIn Writer] Draft update complete');
|
||||
};
|
||||
|
||||
@@ -340,22 +595,6 @@ export function useLinkedInWriter() {
|
||||
console.log('[LinkedIn Writer] Chat memory cleared by user');
|
||||
}, []);
|
||||
|
||||
// Make content available to CopilotKit
|
||||
useCopilotReadable({
|
||||
description: 'Current LinkedIn content draft',
|
||||
value: draft
|
||||
});
|
||||
|
||||
useCopilotReadable({
|
||||
description: 'Context and notes for LinkedIn content',
|
||||
value: context
|
||||
});
|
||||
|
||||
useCopilotReadable({
|
||||
description: 'User preferences for LinkedIn content (tone, industry, audience, style, options)',
|
||||
value: userPreferences
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
draft,
|
||||
@@ -403,11 +642,16 @@ export function useLinkedInWriter() {
|
||||
|
||||
// Utilities
|
||||
trackActionUsage,
|
||||
updateSuggestions,
|
||||
getHistoryLength,
|
||||
savePreferences,
|
||||
summarizeHistory,
|
||||
|
||||
// Direct generation methods
|
||||
generatePost,
|
||||
generateArticle,
|
||||
generateCarousel,
|
||||
generateVideoScript,
|
||||
|
||||
// Grounding data
|
||||
researchSources,
|
||||
citations,
|
||||
|
||||
@@ -24,7 +24,8 @@ export const VALID_TONES = [
|
||||
|
||||
export const VALID_SEARCH_ENGINES = [
|
||||
'google',
|
||||
'tavily'
|
||||
'tavily',
|
||||
'exa'
|
||||
] as const;
|
||||
|
||||
export const VALID_INDUSTRIES = [
|
||||
@@ -157,21 +158,17 @@ export function mapIndustry(industry: string | undefined): string {
|
||||
}
|
||||
|
||||
export function mapSearchEngine(engine: string | undefined): SearchEngine {
|
||||
// Force Google for now until METAPHOR issue is resolved
|
||||
return SearchEngine.GOOGLE;
|
||||
|
||||
/* Original logic - commented out temporarily
|
||||
const eng = normalizeEnum(engine);
|
||||
if (!eng) return SearchEngine.GOOGLE;
|
||||
if (!eng) return SearchEngine.EXA;
|
||||
|
||||
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
||||
if (exact) return exact as SearchEngine;
|
||||
|
||||
if (eng.includes('exa')) return SearchEngine.EXA;
|
||||
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
||||
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
||||
|
||||
return SearchEngine.GOOGLE;
|
||||
*/
|
||||
return SearchEngine.EXA;
|
||||
}
|
||||
|
||||
export function mapResponseType(responseType: string | undefined): string {
|
||||
|
||||
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
interface ParameterDescriptor {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
attributes?: ParameterDescriptor[];
|
||||
}
|
||||
|
||||
interface CopilotActionConfig<TArgs = Record<string, any>> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: ParameterDescriptor[];
|
||||
handler: (args: TArgs) => Promise<any>;
|
||||
}
|
||||
|
||||
export function useCopilotActionTyped<TArgs = Record<string, any>>(
|
||||
config: CopilotActionConfig<TArgs>
|
||||
): void {
|
||||
(useCopilotAction as (config: unknown) => void)(config);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export enum LinkedInTone {
|
||||
|
||||
export enum SearchEngine {
|
||||
GOOGLE = 'google',
|
||||
TAVILY = 'tavily'
|
||||
TAVILY = 'tavily',
|
||||
EXA = 'exa'
|
||||
}
|
||||
|
||||
export enum GroundingLevel {
|
||||
@@ -66,7 +67,7 @@ export interface LinkedInArticleRequest {
|
||||
export interface LinkedInCarouselRequest {
|
||||
topic: string;
|
||||
industry: string;
|
||||
slide_count?: number;
|
||||
number_of_slides?: number;
|
||||
tone?: LinkedInTone;
|
||||
target_audience?: string;
|
||||
key_takeaways?: string[];
|
||||
@@ -238,6 +239,24 @@ export interface LinkedInCommentResponseResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LinkedInEditContentRequest {
|
||||
content: string;
|
||||
edit_type: 'professionalize' | 'optimize_engagement' | 'add_hashtags' | 'adjust_tone' | 'expand' | 'condense' | 'add_cta';
|
||||
industry?: string;
|
||||
tone?: string;
|
||||
target_audience?: string;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface LinkedInEditContentResponse {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
edit_type: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// API client
|
||||
export const linkedInWriterApi = {
|
||||
async health(): Promise<any> {
|
||||
@@ -270,18 +289,64 @@ export const linkedInWriterApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async optimizeProfile(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/optimize-profile', request);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generatePoll(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/generate-poll', request);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateCompanyUpdate(request: any): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/linkedin/generate-company-update', request);
|
||||
async editContent(request: LinkedInEditContentRequest): Promise<LinkedInEditContentResponse> {
|
||||
const { data } = await aiApiClient.post('/api/linkedin/edit-content', request);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Asset Library Save ────────────────────────────────────────────────
|
||||
|
||||
export interface SaveLinkedInAssetParams {
|
||||
title: string;
|
||||
content: string;
|
||||
topic?: string;
|
||||
tags?: string[];
|
||||
assetMetadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SaveLinkedInAssetResult {
|
||||
assetId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a LinkedIn post to the Asset Library.
|
||||
* Uses the generic Content Asset API (POST /api/content-assets/).
|
||||
*/
|
||||
export const saveLinkedInToAssetLibrary = async (
|
||||
params: SaveLinkedInAssetParams
|
||||
): Promise<SaveLinkedInAssetResult> => {
|
||||
// Build a filename from the title
|
||||
const safeTitle = (params.title || 'linkedin-post')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 80);
|
||||
const filename = `${safeTitle}-${Date.now()}.txt`;
|
||||
|
||||
const tags = [
|
||||
'linkedin',
|
||||
'social',
|
||||
'ai_generated',
|
||||
...(params.tags || []),
|
||||
];
|
||||
|
||||
const response = await aiApiClient.post('/api/content-assets/', {
|
||||
asset_type: 'text',
|
||||
source_module: 'linkedin_writer',
|
||||
filename,
|
||||
file_url: `linkedin://posts/${filename}`,
|
||||
title: params.title,
|
||||
description: params.content,
|
||||
prompt: params.topic || '',
|
||||
tags,
|
||||
asset_metadata: {
|
||||
platform: 'linkedin',
|
||||
content_type: 'linkedin_post',
|
||||
word_count: params.content ? params.content.split(/\s+/).length : 0,
|
||||
...(params.assetMetadata || {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { assetId: response.data.id };
|
||||
};
|
||||
|
||||
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface LinkedInWriterGlobals {
|
||||
tempPromptForGeneration?: string;
|
||||
lastBrainstormEvent?: CustomEvent;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'linkedinwriter:copilotSeedFromPrompt': CustomEvent<{ prompt: string }>;
|
||||
'linkedinwriter:runGoogleSearchForIdeas': CustomEvent<{
|
||||
prompt?: string;
|
||||
seed?: string;
|
||||
forceRefresh?: boolean;
|
||||
usePersona?: boolean;
|
||||
useGoogleSearch?: boolean;
|
||||
includeTrending?: boolean;
|
||||
remarketContent?: boolean;
|
||||
}>;
|
||||
'linkedinwriter:updateDraft': CustomEvent<string>;
|
||||
'linkedinwriter:applyEdit': CustomEvent<{ target: string }>;
|
||||
'linkedinwriter:loadingStart': CustomEvent<{ action: string; message: string }>;
|
||||
'linkedinwriter:loadingEnd': CustomEvent<{ error?: string }>;
|
||||
'linkedinwriter:progressInit': CustomEvent<{ steps: Array<{ id: string; label: string }> }>;
|
||||
'linkedinwriter:progressStep': CustomEvent<{
|
||||
id: string;
|
||||
status: 'active' | 'completed' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
'linkedinwriter:progressComplete': CustomEvent;
|
||||
'linkedinwriter:progressError': CustomEvent<{ id: string; details: string }>;
|
||||
'linkedinwriter:updateGroundingData': CustomEvent<{
|
||||
researchSources: any[];
|
||||
citations: any[];
|
||||
qualityMetrics: any;
|
||||
groundingEnabled: boolean;
|
||||
searchQueries: string[];
|
||||
}>;
|
||||
'linkedinwriter:showTodaysTasks': CustomEvent;
|
||||
'linkedinwriter:updateLinkedInPreferences': CustomEvent;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
tempPromptForGeneration?: string;
|
||||
lastBrainstormEvent?: CustomEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user