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"
|
METAPHOR = "metaphor"
|
||||||
GOOGLE = "google"
|
GOOGLE = "google"
|
||||||
TAVILY = "tavily"
|
TAVILY = "tavily"
|
||||||
|
EXA = "exa"
|
||||||
|
|
||||||
|
|
||||||
class GroundingLevel(str, Enum):
|
class GroundingLevel(str, Enum):
|
||||||
@@ -57,7 +58,7 @@ class LinkedInPostRequest(BaseModel):
|
|||||||
include_hashtags: bool = Field(default=True, description="Whether to include hashtags")
|
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")
|
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")
|
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)
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
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")
|
include_images: bool = Field(default=True, description="Whether to generate image suggestions")
|
||||||
seo_optimization: bool = Field(default=True, description="Whether to include SEO optimization")
|
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")
|
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)
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
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)
|
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_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")
|
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")
|
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")
|
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")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -144,9 +147,11 @@ class LinkedInCarouselRequest(BaseModel):
|
|||||||
"number_of_slides": 6,
|
"number_of_slides": 6,
|
||||||
"include_cover_slide": True,
|
"include_cover_slide": True,
|
||||||
"include_cta_slide": True,
|
"include_cta_slide": True,
|
||||||
|
"key_points": ["Remote collaboration tools", "Work-life balance", "Productivity metrics"],
|
||||||
"research_enabled": True,
|
"research_enabled": True,
|
||||||
"search_engine": "google",
|
"search_engine": "google",
|
||||||
"grounding_level": "enhanced",
|
"grounding_level": "enhanced",
|
||||||
|
"color_scheme": "professional",
|
||||||
"include_citations": True
|
"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)
|
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_captions: bool = Field(default=True, description="Whether to include captions")
|
||||||
include_thumbnail_suggestions: bool = Field(default=True, description="Whether to include thumbnail suggestions")
|
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")
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
|
||||||
@@ -176,6 +182,7 @@ class LinkedInVideoScriptRequest(BaseModel):
|
|||||||
"video_duration": 90,
|
"video_duration": 90,
|
||||||
"include_captions": True,
|
"include_captions": True,
|
||||||
"include_thumbnail_suggestions": True,
|
"include_thumbnail_suggestions": True,
|
||||||
|
"key_points": ["Zero trust architecture", "Phishing prevention", "Incident response"],
|
||||||
"research_enabled": True,
|
"research_enabled": True,
|
||||||
"search_engine": "google",
|
"search_engine": "google",
|
||||||
"grounding_level": "enhanced",
|
"grounding_level": "enhanced",
|
||||||
@@ -193,7 +200,7 @@ class LinkedInCommentResponseRequest(BaseModel):
|
|||||||
response_length: str = Field(default="medium", description="Length of response: short, medium, long")
|
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")
|
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")
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.BASIC, description="Level of content grounding")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -452,3 +459,23 @@ class LinkedInCommentResponseResult(BaseModel):
|
|||||||
generation_metadata: Dict[str, Any] = {}
|
generation_metadata: Dict[str, Any] = {}
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
grounding_status: Optional[Dict[str, Any]] = Field(None, description="Grounding operation status")
|
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 import APIRouter, HTTPException, Depends, BackgroundTasks, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,11 +18,17 @@ from models.linkedin_models import (
|
|||||||
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
||||||
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
|
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
|
||||||
LinkedInPostResponse, LinkedInArticleResponse, LinkedInCarouselResponse,
|
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_service import LinkedInService
|
||||||
|
from services.linkedin.carousel import LinkedInCarouselPDFRenderer
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from utils.text_asset_tracker import save_and_track_text_content
|
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
|
# Initialize the LinkedIn service instance
|
||||||
linkedin_service = LinkedInService()
|
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 services.database import get_db as get_db_dependency
|
||||||
from sqlalchemy.orm import Session
|
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
|
# Initialize router
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/linkedin",
|
prefix="/api/linkedin",
|
||||||
@@ -112,10 +147,10 @@ async def generate_post(
|
|||||||
|
|
||||||
# Validate request
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -124,22 +159,30 @@ async def generate_post(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate post content
|
||||||
response = await linkedin_service.generate_linkedin_post(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
log_api_request, http_request, db, duration, 200
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.success:
|
# Save and track text content
|
||||||
raise HTTPException(status_code=500, detail=response.error)
|
|
||||||
|
|
||||||
# Save and track text content (non-blocking)
|
|
||||||
if user_id and response.data and response.data.content:
|
if user_id and response.data and response.data.content:
|
||||||
try:
|
try:
|
||||||
# Combine all text content
|
|
||||||
text_content = response.data.content
|
text_content = response.data.content
|
||||||
if response.data.call_to_action:
|
if response.data.call_to_action:
|
||||||
text_content += f"\n\nCall to Action: {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"
|
subdirectory="posts"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -177,14 +220,13 @@ async def generate_post(
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
logger.error(f"Error generating LinkedIn post: {str(e)}")
|
logger.error(f"Error generating LinkedIn post: {str(e)}")
|
||||||
|
|
||||||
# Log failed request
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 500
|
log_api_request, http_request, db, duration, 500
|
||||||
)
|
)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -234,17 +276,16 @@ async def generate_article(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate article content
|
||||||
response = await linkedin_service.generate_linkedin_article(request)
|
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:
|
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)
|
# Save and track text content (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
@@ -282,7 +323,7 @@ async def generate_article(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -300,7 +341,7 @@ async def generate_article(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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:
|
if request.number_of_slides < 3 or request.number_of_slides > 15:
|
||||||
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
|
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Number of slides must be between 3 and 15"))
|
||||||
|
|
||||||
# Extract user_id
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -352,18 +393,23 @@ async def generate_carousel(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate carousel content
|
||||||
response = await linkedin_service.generate_linkedin_carousel(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
try:
|
try:
|
||||||
@@ -381,10 +427,10 @@ async def generate_carousel(
|
|||||||
source_module="linkedin_writer",
|
source_module="linkedin_writer",
|
||||||
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
||||||
description=f"LinkedIn carousel for {request.industry} industry",
|
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(' ', '_')],
|
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
|
||||||
asset_metadata={
|
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_cover": response.data.cover_slide is not None,
|
||||||
"has_cta": response.data.cta_slide is not None
|
"has_cta": response.data.cta_slide is not None
|
||||||
},
|
},
|
||||||
@@ -392,7 +438,7 @@ async def generate_carousel(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -410,10 +456,82 @@ async def generate_carousel(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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(
|
@router.post(
|
||||||
"/generate-video-script",
|
"/generate-video-script",
|
||||||
response_model=LinkedInVideoScriptResponse,
|
response_model=LinkedInVideoScriptResponse,
|
||||||
@@ -447,14 +565,14 @@ async def generate_video_script(
|
|||||||
|
|
||||||
# Validate request
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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))
|
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
|
||||||
if video_duration < 15 or video_duration > 300:
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -463,18 +581,23 @@ async def generate_video_script(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate video script content
|
||||||
response = await linkedin_service.generate_linkedin_video_script(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
try:
|
try:
|
||||||
@@ -514,7 +637,7 @@ async def generate_video_script(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -532,7 +655,7 @@ async def generate_video_script(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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', ''))
|
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
|
||||||
|
|
||||||
if not original_comment.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -584,18 +707,23 @@ async def generate_comment_response(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate comment response
|
||||||
response = await linkedin_service.generate_linkedin_comment_response(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and hasattr(response, 'response') and response.response:
|
if user_id and hasattr(response, 'response') and response.response:
|
||||||
try:
|
try:
|
||||||
@@ -626,7 +754,7 @@ async def generate_comment_response(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -644,7 +772,7 @@ async def generate_comment_response(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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(
|
@router.get(
|
||||||
"/usage-stats",
|
"/usage-stats",
|
||||||
summary="Get Usage Statistics",
|
summary="Get Usage Statistics",
|
||||||
@@ -699,30 +949,29 @@ async def get_content_types():
|
|||||||
async def get_usage_stats(db: Session = Depends(get_db)):
|
async def get_usage_stats(db: Session = Depends(get_db)):
|
||||||
"""Get usage statistics for LinkedIn content generation."""
|
"""Get usage statistics for LinkedIn content generation."""
|
||||||
try:
|
try:
|
||||||
# This would query the database for actual usage stats
|
base = db.query(APIRequest).filter(APIRequest.path.like('/api/linkedin/%'))
|
||||||
# For now, returning mock data
|
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 {
|
return {
|
||||||
"total_requests": 1250,
|
"total_requests": total,
|
||||||
"content_types": {
|
"content_types": content_types,
|
||||||
"posts": 650,
|
"success_rate": round(successful / max(total, 1), 2),
|
||||||
"articles": 320,
|
"average_generation_time": round(float(avg_dur), 2),
|
||||||
"carousels": 180,
|
|
||||||
"video_scripts": 70,
|
|
||||||
"comment_responses": 30
|
|
||||||
},
|
|
||||||
"success_rate": 0.96,
|
|
||||||
"average_generation_time": 4.2,
|
|
||||||
"top_industries": [
|
|
||||||
"Technology",
|
|
||||||
"Healthcare",
|
|
||||||
"Finance",
|
|
||||||
"Marketing",
|
|
||||||
"Education"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving usage stats: {str(e)}")
|
logger.error(f"Error retrieving usage stats: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
VideoScriptGenerator
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import new image generation services
|
# Import image generation services
|
||||||
from .image_generation import (
|
from .image_generation import (
|
||||||
LinkedInImageGenerator,
|
LinkedInImageGenerator,
|
||||||
LinkedInImageEditor,
|
|
||||||
LinkedInImageStorage
|
LinkedInImageStorage
|
||||||
)
|
)
|
||||||
from .image_prompts import LinkedInPromptGenerator
|
from .image_prompts import LinkedInPromptGenerator
|
||||||
|
from .carousel import LinkedInCarouselPDFRenderer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Content Generation
|
# Content Generation
|
||||||
@@ -42,9 +42,10 @@ __all__ = [
|
|||||||
|
|
||||||
# Image Generation Services
|
# Image Generation Services
|
||||||
'LinkedInImageGenerator',
|
'LinkedInImageGenerator',
|
||||||
'LinkedInImageEditor',
|
|
||||||
'LinkedInImageStorage',
|
'LinkedInImageStorage',
|
||||||
'LinkedInPromptGenerator'
|
'LinkedInPromptGenerator',
|
||||||
|
# Carousel Rendering
|
||||||
|
'LinkedInCarouselPDFRenderer',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Version information
|
# 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
|
Content Generator for LinkedIn Content Generation
|
||||||
|
|
||||||
Handles the main content generation logic for posts and articles.
|
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
|
from typing import Dict, Any, List, Optional
|
||||||
@@ -21,6 +22,7 @@ from services.linkedin.content_generator_prompts import (
|
|||||||
CarouselGenerator,
|
CarouselGenerator,
|
||||||
VideoScriptGenerator
|
VideoScriptGenerator
|
||||||
)
|
)
|
||||||
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
from services.persona_analysis_service import PersonaAnalysisService
|
from services.persona_analysis_service import PersonaAnalysisService
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -28,11 +30,9 @@ import time
|
|||||||
class ContentGenerator:
|
class ContentGenerator:
|
||||||
"""Handles content generation for all LinkedIn content types."""
|
"""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.citation_manager = citation_manager
|
||||||
self.quality_analyzer = quality_analyzer
|
self.quality_analyzer = quality_analyzer
|
||||||
self.gemini_grounded = gemini_grounded
|
|
||||||
self.fallback_provider = fallback_provider
|
|
||||||
|
|
||||||
# Persona caching
|
# Persona caching
|
||||||
self._persona_cache: Dict[str, Dict[str, Any]] = {}
|
self._persona_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -105,22 +105,24 @@ class ContentGenerator:
|
|||||||
del self._cache_timestamps[key]
|
del self._cache_timestamps[key]
|
||||||
logger.info(f"Cleared persona cache for user {user_id}")
|
logger.info(f"Cleared persona cache for user {user_id}")
|
||||||
|
|
||||||
def _transform_gemini_sources(self, gemini_sources):
|
def _build_research_context(self, research_sources: List) -> str:
|
||||||
"""Transform Gemini sources to ResearchSource format."""
|
"""Build research context string from research sources for prompt injection."""
|
||||||
transformed_sources = []
|
if not research_sources:
|
||||||
for source in gemini_sources:
|
return ""
|
||||||
transformed_source = ResearchSource(
|
|
||||||
title=source.get('title', 'Unknown Source'),
|
context_parts = ["\n\nRESEARCH CONTEXT (use this information to ground your content with facts and data):"]
|
||||||
url=source.get('url', ''),
|
for i, source in enumerate(research_sources[:5], 1): # Limit to top 5 sources
|
||||||
content=f"Source from {source.get('title', 'Unknown')}",
|
title = getattr(source, 'title', f'Source {i}')
|
||||||
relevance_score=0.8, # Default relevance score
|
url = getattr(source, 'url', '')
|
||||||
credibility_score=0.7, # Default credibility score
|
content = getattr(source, 'content', '')
|
||||||
domain_authority=0.6, # Default domain authority
|
context_parts.append(f"\n{i}. {title}")
|
||||||
source_type=source.get('type', 'web'),
|
if url:
|
||||||
publication_date=datetime.now().strftime('%Y-%m-%d')
|
context_parts.append(f" URL: {url}")
|
||||||
)
|
if content:
|
||||||
transformed_sources.append(transformed_source)
|
context_parts.append(f" Key insight: {content[:300]}")
|
||||||
return transformed_sources
|
|
||||||
|
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(
|
async def generate_post(
|
||||||
self,
|
self,
|
||||||
@@ -155,21 +157,12 @@ class ContentGenerator:
|
|||||||
logger.info(f" - First research source: {research_sources[0] if research_sources else 'None'}")
|
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]]}")
|
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 = []
|
citations = []
|
||||||
source_list = None
|
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 request.include_citations and research_sources and self.citation_manager:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
|
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
|
||||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||||
@@ -224,7 +217,7 @@ class ContentGenerator:
|
|||||||
data=post_content,
|
data=post_content,
|
||||||
research_sources=final_research_sources, # Use final_research_sources
|
research_sources=final_research_sources, # Use final_research_sources
|
||||||
generation_metadata={
|
generation_metadata={
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -251,21 +244,12 @@ class ContentGenerator:
|
|||||||
try:
|
try:
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
# Step 3: Add citations if requested - ARTICLE METHOD
|
# Step 3: Add citations if requested
|
||||||
citations = []
|
citations = []
|
||||||
source_list = None
|
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 request.include_citations and research_sources and self.citation_manager:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||||
source_list = self.citation_manager.generate_source_list(research_sources)
|
source_list = self.citation_manager.generate_source_list(research_sources)
|
||||||
@@ -317,7 +301,7 @@ class ContentGenerator:
|
|||||||
data=article_content,
|
data=article_content,
|
||||||
research_sources=final_research_sources, # Use final_research_sources
|
research_sources=final_research_sources, # Use final_research_sources
|
||||||
generation_metadata={
|
generation_metadata={
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -386,7 +370,7 @@ class ContentGenerator:
|
|||||||
'alternative_responses': content_result.get('alternative_responses', []),
|
'alternative_responses': content_result.get('alternative_responses', []),
|
||||||
'tone_analysis': content_result.get('tone_analysis'),
|
'tone_analysis': content_result.get('tone_analysis'),
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -402,19 +386,14 @@ class ContentGenerator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Grounded content generation methods
|
# Grounded content generation methods
|
||||||
async def generate_grounded_post_content(self, request, research_sources: List) -> Dict[str, Any]:
|
async def generate_grounded_post_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded post content using the enhanced Gemini provider with native grounding."""
|
"""Generate post content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
try:
|
||||||
if not self.gemini_grounded:
|
# Build the prompt using persona if available
|
||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
uid = int(getattr(request, "user_id", 0) or 0)
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||||
|
|
||||||
# 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')
|
|
||||||
if getattr(request, 'persona_override', None):
|
if getattr(request, 'persona_override', None):
|
||||||
try:
|
try:
|
||||||
# Merge shallowly: override core and platform adaptation parts
|
|
||||||
override = request.persona_override
|
override = request.persona_override
|
||||||
if persona_data:
|
if persona_data:
|
||||||
core = persona_data.get('core_persona', {})
|
core = persona_data.get('core_persona', {})
|
||||||
@@ -431,61 +410,40 @@ class ContentGenerator:
|
|||||||
pass
|
pass
|
||||||
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
|
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_post",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_post",
|
||||||
max_tokens=request.max_length
|
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 {
|
return {
|
||||||
'content': result.get('content', '') if isinstance(result, dict) else str(result),
|
'content': content_text,
|
||||||
'sources': [],
|
'sources': [],
|
||||||
'citations': [],
|
'citations': [],
|
||||||
'grounding_enabled': False,
|
'grounding_enabled': bool(research_sources),
|
||||||
'fallback_used': True
|
'fallback_used': False
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as fallback_error:
|
except Exception as e:
|
||||||
logger.error(f"Fallback generation also failed: {str(fallback_error)}")
|
logger.error(f"Error generating post content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate content: {str(e)}. Fallback also failed: {str(fallback_error)}")
|
raise Exception(f"Failed to generate LinkedIn post: {str(e)}")
|
||||||
|
|
||||||
async def generate_grounded_article_content(self, request, research_sources: List) -> Dict[str, Any]:
|
async def generate_grounded_article_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded article content using the enhanced Gemini provider with native grounding."""
|
"""Generate article content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
try:
|
||||||
if not self.gemini_grounded:
|
# Build the prompt using persona if available
|
||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
uid = int(getattr(request, "user_id", 0) or 0)
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||||
|
|
||||||
# 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')
|
|
||||||
if getattr(request, 'persona_override', None):
|
if getattr(request, 'persona_override', None):
|
||||||
try:
|
try:
|
||||||
override = request.persona_override
|
override = request.persona_override
|
||||||
@@ -504,88 +462,129 @@ class ContentGenerator:
|
|||||||
pass
|
pass
|
||||||
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
|
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_article",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_article",
|
||||||
max_tokens=request.word_count * 10 # Approximate character count
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded article content: {str(e)}")
|
logger.error(f"Error generating article content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_carousel_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded carousel content using the enhanced Gemini provider with native grounding."""
|
"""Generate carousel content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = CarouselPromptBuilder.build_carousel_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_carousel",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_carousel",
|
||||||
max_tokens=2000
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded carousel content: {str(e)}")
|
logger.error(f"Error generating carousel content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_video_script_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded video script content using the enhanced Gemini provider with native grounding."""
|
"""Generate video script content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = VideoScriptPromptBuilder.build_video_script_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_video_script",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_video_script",
|
||||||
max_tokens=1500
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded video script content: {str(e)}")
|
logger.error(f"Error generating video script content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_comment_response(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded comment response using the enhanced Gemini provider with native grounding."""
|
"""Generate comment response using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = CommentResponsePromptBuilder.build_comment_response_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_comment_response",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_comment_response",
|
||||||
max_tokens=2000
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded comment response: {str(e)}")
|
logger.error(f"Error generating comment response: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded comment response: {str(e)}")
|
raise Exception(f"Failed to generate LinkedIn comment response: {str(e)}")
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class CarouselGenerator:
|
|||||||
'data': carousel_content,
|
'data': carousel_content,
|
||||||
'research_sources': research_sources,
|
'research_sources': research_sources,
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class VideoScriptGenerator:
|
|||||||
'data': video_script,
|
'data': video_script,
|
||||||
'research_sources': research_sources,
|
'research_sources': research_sources,
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
LinkedIn Image Generation Package
|
LinkedIn Image Generation Package
|
||||||
|
|
||||||
This package provides AI-powered image generation capabilities for LinkedIn content
|
This package provides AI-powered image generation capabilities for LinkedIn content
|
||||||
using Google's Gemini API. It includes image generation, editing, storage, and
|
using the common llm_providers infrastructure. It includes image generation, storage,
|
||||||
management services optimized for professional business use.
|
and management services optimized for professional business use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .linkedin_image_generator import LinkedInImageGenerator
|
from .linkedin_image_generator import LinkedInImageGenerator
|
||||||
from .linkedin_image_editor import LinkedInImageEditor
|
|
||||||
from .linkedin_image_storage import LinkedInImageStorage
|
from .linkedin_image_storage import LinkedInImageStorage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'LinkedInImageGenerator',
|
'LinkedInImageGenerator',
|
||||||
'LinkedInImageEditor',
|
|
||||||
'LinkedInImageStorage'
|
'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
|
LinkedIn Image Generator Service
|
||||||
|
|
||||||
This service generates LinkedIn-optimized images using Google's Gemini API.
|
This service generates LinkedIn-optimized images using the common
|
||||||
It provides professional, business-appropriate imagery for LinkedIn content.
|
llm_providers infrastructure. It provides professional, business-appropriate
|
||||||
|
imagery for LinkedIn content.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -17,6 +18,7 @@ from io import BytesIO
|
|||||||
# Import existing infrastructure
|
# Import existing infrastructure
|
||||||
from ...onboarding.api_key_manager import APIKeyManager
|
from ...onboarding.api_key_manager import APIKeyManager
|
||||||
from ...llm_providers.main_image_generation import generate_image
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,9 +26,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class LinkedInImageGenerator:
|
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 provides LinkedIn-specific image optimization, quality assurance,
|
||||||
and professional business aesthetics.
|
and professional business aesthetics.
|
||||||
"""
|
"""
|
||||||
@@ -36,10 +38,9 @@ class LinkedInImageGenerator:
|
|||||||
Initialize the LinkedIn Image Generator.
|
Initialize the LinkedIn Image Generator.
|
||||||
|
|
||||||
Args:
|
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.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.default_aspect_ratio = "1:1" # LinkedIn post optimal ratio
|
||||||
self.max_retries = 3
|
self.max_retries = 3
|
||||||
|
|
||||||
@@ -55,16 +56,18 @@ class LinkedInImageGenerator:
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
content_context: Dict[str, Any],
|
content_context: Dict[str, Any],
|
||||||
aspect_ratio: str = "1:1",
|
aspect_ratio: str = "1:1",
|
||||||
style_preference: str = "professional"
|
style_preference: str = "professional",
|
||||||
|
user_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate LinkedIn-optimized image using Gemini API.
|
Generate LinkedIn-optimized image using AI provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: User's image generation prompt
|
prompt: User's image generation prompt
|
||||||
content_context: LinkedIn content context (topic, industry, content_type)
|
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)
|
style_preference: Style preference (professional, creative, industry-specific)
|
||||||
|
user_id: User ID for tenant provider resolution
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing generation result, image data, and metadata
|
Dict containing generation result, image data, and metadata
|
||||||
@@ -78,8 +81,8 @@ class LinkedInImageGenerator:
|
|||||||
prompt, content_context, style_preference, aspect_ratio
|
prompt, content_context, style_preference, aspect_ratio
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate image using existing Gemini infrastructure
|
# Generate image using tenant-aware provider selection
|
||||||
generation_result = await self._generate_with_gemini(enhanced_prompt, aspect_ratio)
|
generation_result = await self._generate_with_provider(enhanced_prompt, aspect_ratio, user_id)
|
||||||
|
|
||||||
if not generation_result.get('success'):
|
if not generation_result.get('success'):
|
||||||
return {
|
return {
|
||||||
@@ -108,7 +111,7 @@ class LinkedInImageGenerator:
|
|||||||
'aspect_ratio': aspect_ratio,
|
'aspect_ratio': aspect_ratio,
|
||||||
'content_context': content_context,
|
'content_context': content_context,
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'model_used': self.model,
|
'model_used': generation_result.get('model'),
|
||||||
'image_format': processed_image['format'],
|
'image_format': processed_image['format'],
|
||||||
'image_size': processed_image['size'],
|
'image_size': processed_image['size'],
|
||||||
'resolution': processed_image['resolution']
|
'resolution': processed_image['resolution']
|
||||||
@@ -131,17 +134,19 @@ class LinkedInImageGenerator:
|
|||||||
|
|
||||||
async def edit_image(
|
async def edit_image(
|
||||||
self,
|
self,
|
||||||
base_image: bytes,
|
input_image_bytes: bytes,
|
||||||
edit_prompt: str,
|
edit_prompt: str,
|
||||||
content_context: Dict[str, Any]
|
content_context: Dict[str, Any],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Edit existing image using Gemini's conversational editing capabilities.
|
Edit existing image using unified image editing infrastructure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_image: Base image data in bytes
|
input_image_bytes: Input image bytes to edit
|
||||||
edit_prompt: Description of desired edits
|
edit_prompt: Description of desired edits
|
||||||
content_context: LinkedIn content context for optimization
|
content_context: LinkedIn content context for optimization
|
||||||
|
user_id: User ID for tenant provider resolution and subscription checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing edited image result and metadata
|
Dict containing edited image result and metadata
|
||||||
@@ -155,18 +160,46 @@ class LinkedInImageGenerator:
|
|||||||
edit_prompt, content_context
|
edit_prompt, content_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use Gemini's image editing capabilities
|
# Use unified image editing system.
|
||||||
# Note: This will be implemented when Gemini's image editing is fully available
|
# common_edit_image() handles: provider resolution, pre-flight validation,
|
||||||
# For now, we'll return a placeholder implementation
|
# 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 {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Image editing not yet implemented - coming in next Gemini API update',
|
'error': 'Image editing returned no result',
|
||||||
'generation_time': (datetime.now() - start_time).total_seconds()
|
'generation_time': (datetime.now() - start_time).total_seconds(),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
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 {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f"Image editing failed: {str(e)}",
|
'error': f"Image editing failed: {str(e)}",
|
||||||
@@ -268,13 +301,16 @@ class LinkedInImageGenerator:
|
|||||||
|
|
||||||
return enhanced_edit_prompt
|
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.
|
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:
|
Args:
|
||||||
prompt: Enhanced prompt for image generation
|
prompt: Enhanced prompt for image generation
|
||||||
aspect_ratio: Desired aspect ratio
|
aspect_ratio: Desired aspect ratio
|
||||||
|
user_id: User ID for tenant provider resolution and subscription checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Generation result from image generation provider
|
Generation result from image generation provider
|
||||||
@@ -285,26 +321,31 @@ class LinkedInImageGenerator:
|
|||||||
"1:1": (1024, 1024),
|
"1:1": (1024, 1024),
|
||||||
"16:9": (1920, 1080),
|
"16:9": (1920, 1080),
|
||||||
"4:3": (1366, 1024),
|
"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))
|
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(
|
result = generate_image(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
options={
|
options={
|
||||||
"provider": "gemini", # LinkedIn uses Gemini by default
|
|
||||||
"model": self.model if hasattr(self, 'model') else None,
|
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
}
|
},
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if result and result.image_bytes:
|
if result and result.image_bytes:
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'image_data': result.image_bytes,
|
'image_data': result.image_bytes,
|
||||||
'image_path': None, # No file path, using bytes directly
|
'image_path': None,
|
||||||
'width': result.width,
|
'width': result.width,
|
||||||
'height': result.height,
|
'height': result.height,
|
||||||
'provider': result.provider,
|
'provider': result.provider,
|
||||||
@@ -487,6 +528,9 @@ class LinkedInImageGenerator:
|
|||||||
(1.6, 1.8), # 16:9 (landscape)
|
(1.6, 1.8), # 16:9 (landscape)
|
||||||
(0.7, 0.8), # 4:3 (portrait)
|
(0.7, 0.8), # 4:3 (portrait)
|
||||||
(1.2, 1.4), # 5:4 (landscape)
|
(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:
|
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 os
|
||||||
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -58,6 +60,8 @@ class LinkedInImageStorage:
|
|||||||
self.max_storage_size_gb = 10 # Maximum storage size in GB
|
self.max_storage_size_gb = 10 # Maximum storage size in GB
|
||||||
self.image_retention_days = 30 # Days to keep images
|
self.image_retention_days = 30 # Days to keep images
|
||||||
self.max_image_size_mb = 10 # Maximum individual image size in MB
|
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}")
|
logger.info(f"LinkedIn Image Storage initialized at {self.base_storage_path}")
|
||||||
|
|
||||||
@@ -102,6 +106,22 @@ class LinkedInImageStorage:
|
|||||||
try:
|
try:
|
||||||
start_time = datetime.now()
|
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
|
# Generate unique image ID
|
||||||
image_id = self._generate_image_id(image_data, metadata)
|
image_id = self._generate_image_id(image_data, metadata)
|
||||||
|
|
||||||
@@ -170,6 +190,9 @@ class LinkedInImageStorage:
|
|||||||
Dict containing image data and metadata
|
Dict containing image data and metadata
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not self._validate_image_id(image_id):
|
||||||
|
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||||
|
|
||||||
# Find image file
|
# Find image file
|
||||||
image_path = await self._find_image_by_id(image_id, user_id)
|
image_path = await self._find_image_by_id(image_id, user_id)
|
||||||
if not image_path:
|
if not image_path:
|
||||||
@@ -216,6 +239,9 @@ class LinkedInImageStorage:
|
|||||||
Dict containing deletion result
|
Dict containing deletion result
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not self._validate_image_id(image_id):
|
||||||
|
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||||
|
|
||||||
# Find image file
|
# Find image file
|
||||||
image_path = await self._find_image_by_id(image_id, user_id)
|
image_path = await self._find_image_by_id(image_id, user_id)
|
||||||
if not image_path:
|
if not image_path:
|
||||||
@@ -418,6 +444,32 @@ class LinkedInImageStorage:
|
|||||||
'error': f"Failed to get storage stats: {str(e)}"
|
'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:
|
def _generate_image_id(self, image_data: bytes, metadata: Dict[str, Any]) -> str:
|
||||||
"""Generate unique image ID based on content and metadata."""
|
"""Generate unique image ID based on content and metadata."""
|
||||||
# Create hash from image data and key metadata
|
# Create hash from image data and key metadata
|
||||||
@@ -569,6 +621,9 @@ class LinkedInImageStorage:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict containing image metadata if found
|
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)
|
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]]:
|
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
|
LinkedIn Image Prompts Package
|
||||||
|
|
||||||
This package provides AI-powered image prompt generation for LinkedIn content
|
This package provides AI-powered image prompt generation for LinkedIn content
|
||||||
using Google's Gemini API. It creates three distinct prompt styles optimized
|
using the provider-agnostic llm_text_gen gateway. It creates three distinct
|
||||||
for professional business image generation.
|
prompt styles optimized for professional business image generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .linkedin_prompt_generator import LinkedInPromptGenerator
|
from .linkedin_prompt_generator import LinkedInPromptGenerator
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Image Prompt Generator Service
|
LinkedIn Image Prompt Generator Service
|
||||||
|
|
||||||
This service generates AI-optimized image prompts for LinkedIn content using Gemini's
|
This service generates AI-optimized image prompts for LinkedIn content using
|
||||||
capabilities. It creates three distinct prompt styles (professional, creative, industry-specific)
|
the provider-agnostic llm_text_gen gateway. It creates three distinct prompt
|
||||||
following best practices for image generation.
|
styles (professional, creative, industry-specific) following best practices
|
||||||
|
for image generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -13,14 +14,14 @@ from loguru import logger
|
|||||||
|
|
||||||
# Import existing infrastructure
|
# Import existing infrastructure
|
||||||
from ...onboarding.api_key_manager import APIKeyManager
|
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:
|
class LinkedInPromptGenerator:
|
||||||
"""
|
"""
|
||||||
Generates AI-optimized image prompts for LinkedIn content.
|
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
|
1. Professional Style - Corporate aesthetics, clean lines, business colors
|
||||||
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
|
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
|
||||||
3. Industry-Specific Style - Tailored to specific business sectors
|
3. Industry-Specific Style - Tailored to specific business sectors
|
||||||
@@ -31,10 +32,9 @@ class LinkedInPromptGenerator:
|
|||||||
Initialize the LinkedIn Prompt Generator.
|
Initialize the LinkedIn Prompt Generator.
|
||||||
|
|
||||||
Args:
|
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.api_key_manager = api_key_manager or APIKeyManager()
|
||||||
self.model = "gemini-2.0-flash-exp"
|
|
||||||
|
|
||||||
# Prompt generation configuration
|
# Prompt generation configuration
|
||||||
self.max_prompt_length = 500
|
self.max_prompt_length = 500
|
||||||
@@ -49,7 +49,8 @@ class LinkedInPromptGenerator:
|
|||||||
async def generate_three_prompts(
|
async def generate_three_prompts(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str = "1:1"
|
aspect_ratio: str = "1:1",
|
||||||
|
user_id: str = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Generate three AI-optimized image prompts for LinkedIn content.
|
Generate three AI-optimized image prompts for LinkedIn content.
|
||||||
@@ -57,6 +58,7 @@ class LinkedInPromptGenerator:
|
|||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
|
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
|
||||||
aspect_ratio: Desired image aspect ratio
|
aspect_ratio: Desired image aspect ratio
|
||||||
|
user_id: User ID for subscription checking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of three prompt objects with style, prompt, and description
|
List of three prompt objects with style, prompt, and description
|
||||||
@@ -65,11 +67,11 @@ class LinkedInPromptGenerator:
|
|||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
|
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
|
||||||
|
|
||||||
# Generate prompts using Gemini
|
# Generate prompts using provider-agnostic gateway
|
||||||
prompts = await self._generate_prompts_with_gemini(linkedin_content, aspect_ratio)
|
prompts = await self._generate_prompts_with_llm(linkedin_content, aspect_ratio, user_id)
|
||||||
|
|
||||||
if not prompts or len(prompts) < 3:
|
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)
|
prompts = self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
# Ensure exactly 3 prompts
|
# Ensure exactly 3 prompts
|
||||||
@@ -92,62 +94,65 @@ class LinkedInPromptGenerator:
|
|||||||
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
|
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
|
||||||
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
async def _generate_prompts_with_gemini(
|
async def _generate_prompts_with_llm(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str
|
aspect_ratio: str,
|
||||||
|
user_id: str = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Generate image prompts using Gemini AI.
|
Generate image prompts using provider-agnostic llm_text_gen.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
aspect_ratio: Image aspect ratio
|
aspect_ratio: Image aspect ratio
|
||||||
|
user_id: User ID for subscription checking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of generated prompts
|
List of generated prompts
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build the prompt for Gemini
|
# Build the prompt
|
||||||
gemini_prompt = self._build_gemini_prompt(linkedin_content, aspect_ratio)
|
prompt = self._build_image_prompt(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
# Generate response using Gemini
|
# Generate response using provider-agnostic gateway
|
||||||
response = gemini_text_response(
|
response = llm_text_gen(
|
||||||
prompt=gemini_prompt,
|
prompt=prompt,
|
||||||
temperature=0.7,
|
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization.",
|
||||||
top_p=0.8,
|
user_id=user_id,
|
||||||
n=1,
|
flow_type="linkedin_image_prompts",
|
||||||
max_tokens=1000,
|
max_tokens=1000,
|
||||||
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization."
|
temperature=0.7
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
logger.warning("No response from Gemini prompt generation")
|
logger.warning("No response from prompt generation")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Parse Gemini response into structured prompts
|
# Parse response into structured prompts
|
||||||
prompts = self._parse_gemini_response(response, linkedin_content)
|
response_text = response if isinstance(response, str) else str(response or "")
|
||||||
|
prompts = self._parse_llm_response(response_text, linkedin_content)
|
||||||
|
|
||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in Gemini prompt generation: {str(e)}")
|
logger.error(f"Error in prompt generation: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _build_gemini_prompt(
|
def _build_image_prompt(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str
|
aspect_ratio: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build comprehensive prompt for Gemini to generate image prompts.
|
Build comprehensive prompt for LLM to generate image prompts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
aspect_ratio: Image aspect ratio
|
aspect_ratio: Image aspect ratio
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Formatted prompt for Gemini
|
Formatted prompt for LLM
|
||||||
"""
|
"""
|
||||||
topic = linkedin_content.get('topic', 'business')
|
topic = linkedin_content.get('topic', 'business')
|
||||||
industry = linkedin_content.get('industry', 'business')
|
industry = linkedin_content.get('industry', 'business')
|
||||||
@@ -428,16 +433,16 @@ class LinkedInPromptGenerator:
|
|||||||
else:
|
else:
|
||||||
return 'Informational & Awareness'
|
return 'Informational & Awareness'
|
||||||
|
|
||||||
def _parse_gemini_response(
|
def _parse_llm_response(
|
||||||
self,
|
self,
|
||||||
response: str,
|
response: str,
|
||||||
linkedin_content: Dict[str, Any]
|
linkedin_content: Dict[str, Any]
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Parse Gemini response into structured prompt objects.
|
Parse LLM response into structured prompt objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: Raw response from Gemini
|
response: Raw response from LLM
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -462,7 +467,7 @@ class LinkedInPromptGenerator:
|
|||||||
return self._parse_response_manually(response, linkedin_content)
|
return self._parse_response_manually(response, linkedin_content)
|
||||||
|
|
||||||
except Exception as e:
|
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)
|
return self._parse_response_manually(response, linkedin_content)
|
||||||
|
|
||||||
def _parse_response_manually(
|
def _parse_response_manually(
|
||||||
@@ -474,7 +479,7 @@ class LinkedInPromptGenerator:
|
|||||||
Manually parse response if JSON parsing fails.
|
Manually parse response if JSON parsing fails.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: Raw response from Gemini
|
response: Raw response from LLM
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
Research Handler for LinkedIn Content Generation
|
Research Handler for LinkedIn Content Generation
|
||||||
|
|
||||||
Handles research operations and timing for 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 datetime import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from models.linkedin_models import ResearchSource
|
from models.linkedin_models import ResearchSource
|
||||||
@@ -21,11 +22,19 @@ class ResearchHandler:
|
|||||||
request,
|
request,
|
||||||
research_enabled: bool,
|
research_enabled: bool,
|
||||||
search_engine: str,
|
search_engine: str,
|
||||||
max_results: int = 10
|
max_results: int = 10,
|
||||||
|
user_id: Optional[str] = None
|
||||||
) -> tuple[List[ResearchSource], float]:
|
) -> tuple[List[ResearchSource], float]:
|
||||||
"""
|
"""
|
||||||
Conduct research if enabled and return sources with timing.
|
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:
|
Returns:
|
||||||
Tuple of (research_sources, research_time)
|
Tuple of (research_sources, research_time)
|
||||||
"""
|
"""
|
||||||
@@ -33,7 +42,6 @@ class ResearchHandler:
|
|||||||
research_time = 0
|
research_time = 0
|
||||||
|
|
||||||
if research_enabled:
|
if research_enabled:
|
||||||
# Debug: Log the search engine value being passed
|
|
||||||
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
|
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
|
||||||
|
|
||||||
research_start = datetime.now()
|
research_start = datetime.now()
|
||||||
@@ -41,7 +49,8 @@ class ResearchHandler:
|
|||||||
topic=request.topic,
|
topic=request.topic,
|
||||||
industry=request.industry,
|
industry=request.industry,
|
||||||
search_engine=search_engine,
|
search_engine=search_engine,
|
||||||
max_results=max_results
|
max_results=max_results,
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
research_time = (datetime.now() - research_start).total_seconds()
|
research_time = (datetime.now() - research_start).total_seconds()
|
||||||
logger.info(f"Research completed in {research_time:.2f}s, found {len(research_sources)} sources")
|
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':
|
if not research_enabled or level == 'none':
|
||||||
return False
|
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
|
# For other engines, require that research actually returned sources
|
||||||
return bool(research_sources)
|
return bool(research_sources)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Content Generation Service for ALwrity
|
LinkedIn Content Generation Service for ALwrity
|
||||||
|
|
||||||
This service generates various types of LinkedIn content with enhanced grounding capabilities.
|
This service generates various types of LinkedIn content with provider-agnostic
|
||||||
Integrated with Google Search, Gemini Grounded Provider, and quality analysis.
|
LLM access via llm_text_gen. Research is handled by Exa/Tavily through the
|
||||||
|
common research infrastructure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -21,57 +22,44 @@ from models.linkedin_models import (
|
|||||||
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
|
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
|
||||||
GroundingLevel
|
GroundingLevel
|
||||||
)
|
)
|
||||||
from services.research import GoogleSearchService
|
|
||||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
|
||||||
from services.citation import CitationManager
|
from services.citation import CitationManager
|
||||||
from services.quality import ContentQualityAnalyzer
|
from services.quality import ContentQualityAnalyzer
|
||||||
|
|
||||||
|
|
||||||
class LinkedInService:
|
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,
|
Uses llm_text_gen for text generation (respects GPT_PROVIDER).
|
||||||
citation management, and quality analysis for enterprise-grade content.
|
Uses Exa/Tavily for research via common infrastructure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the LinkedIn service with all required components."""
|
"""Initialize the LinkedIn service with lazy provider initialization."""
|
||||||
# Google Search Service not used - removed to avoid false warnings
|
self._citation_manager = None
|
||||||
self.google_search = None
|
self._quality_analyzer = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def citation_manager(self):
|
||||||
|
if self._citation_manager is None:
|
||||||
try:
|
try:
|
||||||
self.gemini_grounded = GeminiGroundedProvider()
|
self._citation_manager = CitationManager()
|
||||||
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()
|
|
||||||
logger.info("✅ Citation Manager initialized")
|
logger.info("✅ Citation Manager initialized")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Citation Manager not available: {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:
|
try:
|
||||||
self.quality_analyzer = ContentQualityAnalyzer()
|
self._quality_analyzer = ContentQualityAnalyzer()
|
||||||
logger.info("✅ Content Quality Analyzer initialized")
|
logger.info("✅ Content Quality Analyzer initialized")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
|
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
|
||||||
self.quality_analyzer = None
|
self._quality_analyzer = None
|
||||||
|
return self._quality_analyzer
|
||||||
# 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
|
|
||||||
|
|
||||||
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
|
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
|
||||||
"""
|
"""
|
||||||
@@ -94,8 +82,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -105,15 +94,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_post_content(
|
content_result = await content_generator.generate_grounded_post_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled, Error generating LinkedIn post")
|
logger.error("Grounding not enabled, Error generating LinkedIn post")
|
||||||
@@ -152,8 +140,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -163,15 +152,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_article_content(
|
content_result = await content_generator.generate_grounded_article_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
|
||||||
@@ -210,8 +198,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -221,15 +210,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_carousel_content(
|
content_result = await content_generator.generate_grounded_carousel_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
|
||||||
@@ -303,8 +291,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -314,15 +303,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_video_script_content(
|
content_result = await content_generator.generate_grounded_video_script_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn video script without AI provider")
|
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
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate response based on grounding level
|
||||||
@@ -398,15 +387,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
response_result = await content_generator.generate_grounded_comment_response(
|
response_result = await content_generator.generate_grounded_comment_response(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
|
||||||
@@ -423,20 +411,13 @@ class LinkedInService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result['success']:
|
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(
|
return LinkedInCommentResponseResult(
|
||||||
success=True,
|
success=True,
|
||||||
data=comment_response,
|
response=result['response'],
|
||||||
research_sources=result['research_sources'],
|
alternative_responses=result.get('alternative_responses', []),
|
||||||
generation_metadata=result['generation_metadata'],
|
tone_analysis=result.get('tone_analysis'),
|
||||||
grounding_status=result['grounding_status']
|
generation_metadata=result.get('generation_metadata', {}),
|
||||||
|
grounding_status=result.get('grounding_status')
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return LinkedInCommentResponseResult(
|
return LinkedInCommentResponseResult(
|
||||||
@@ -451,35 +432,187 @@ class LinkedInService:
|
|||||||
error=f"Failed to generate LinkedIn comment response: {str(e)}"
|
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.
|
Conduct research using the configured search engine with caching.
|
||||||
The Gemini API handles search automatically when the google_search tool is enabled.
|
|
||||||
|
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:
|
Args:
|
||||||
topic: Research topic
|
topic: Research topic
|
||||||
industry: Target industry
|
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
|
max_results: Maximum number of results to return
|
||||||
|
user_id: User ID for subscription pre-flight validation and usage tracking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of research sources (empty for google - sources come from grounding metadata)
|
List of research sources
|
||||||
"""
|
"""
|
||||||
try:
|
from services.cache.research_cache import research_cache
|
||||||
# Debug: Log the search engine value received
|
|
||||||
logger.info(f"Received search engine: '{search_engine}' (type: {type(search_engine)})")
|
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:
|
else:
|
||||||
# Fallback to basic research for other search engines
|
logger.warning(f"Unknown search engine '{search_engine}', no research performed")
|
||||||
logger.error(f"Search engine {search_engine} not fully implemented, using fallback")
|
return []
|
||||||
raise Exception(f"Search engine {search_engine} not fully implemented, using fallback")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error conducting research: {str(e)}")
|
logger.error(f"Research failed for engine {search_engine}: {e}")
|
||||||
# Fallback to basic research
|
return []
|
||||||
raise Exception(f"Error conducting research: {str(e)}")
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Persona Service
|
LinkedIn Persona Service
|
||||||
Handles LinkedIn-specific persona generation and optimization.
|
Handles LinkedIn-specific persona generation and optimization.
|
||||||
|
Uses provider-agnostic llm_text_gen for LLM access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from loguru import logger
|
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_prompts import LinkedInPersonaPrompts
|
||||||
from .linkedin_persona_schemas import LinkedInPersonaSchemas
|
from .linkedin_persona_schemas import LinkedInPersonaSchemas
|
||||||
|
|
||||||
@@ -57,14 +58,15 @@ class LinkedInPersonaService:
|
|||||||
# Extract user_id for tracking
|
# Extract user_id for tracking
|
||||||
user_id = onboarding_data.get("session_info", {}).get("user_id")
|
user_id = onboarding_data.get("session_info", {}).get("user_id")
|
||||||
|
|
||||||
# Generate structured response using Gemini with optimized prompts
|
# Generate structured response using provider-agnostic gateway
|
||||||
response = gemini_structured_json_response(
|
response = llm_text_gen(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
schema=schema,
|
json_struct=schema,
|
||||||
temperature=0.2,
|
|
||||||
max_tokens=4096,
|
|
||||||
system_prompt=system_prompt,
|
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:
|
if "error" in response:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ replacing mock research with real-time industry information.
|
|||||||
Available Services:
|
Available Services:
|
||||||
- GoogleSearchService: Real-time industry research using Google Custom Search API
|
- GoogleSearchService: Real-time industry research using Google Custom Search API
|
||||||
- ExaService: Competitor discovery and analysis using Exa 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
|
- TavilyService: AI-powered web search with real-time information
|
||||||
- Source ranking and credibility assessment
|
- Source ranking and credibility assessment
|
||||||
- Content extraction and insight generation
|
- Content extraction and insight generation
|
||||||
@@ -17,12 +18,13 @@ Core Module (v2.0):
|
|||||||
- ParameterOptimizer: AI-driven parameter optimization
|
- ParameterOptimizer: AI-driven parameter optimization
|
||||||
|
|
||||||
Author: ALwrity Team
|
Author: ALwrity Team
|
||||||
Version: 2.0
|
Version: 2.1
|
||||||
Last Updated: December 2025
|
Last Updated: June 2026
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .google_search_service import GoogleSearchService
|
from .google_search_service import GoogleSearchService
|
||||||
from .exa_service import ExaService
|
from .exa_service import ExaService
|
||||||
|
from .exa_content_research import ExaContentResearchProvider, get_exa_content_provider
|
||||||
from .tavily_service import TavilyService
|
from .tavily_service import TavilyService
|
||||||
|
|
||||||
# Core Research Engine (v2.0)
|
# Core Research Engine (v2.0)
|
||||||
@@ -43,6 +45,10 @@ __all__ = [
|
|||||||
"ExaService",
|
"ExaService",
|
||||||
"TavilyService",
|
"TavilyService",
|
||||||
|
|
||||||
|
# Shared content research provider
|
||||||
|
"ExaContentResearchProvider",
|
||||||
|
"get_exa_content_provider",
|
||||||
|
|
||||||
# Core Research Engine (v2.0)
|
# Core Research Engine (v2.0)
|
||||||
"ResearchEngine",
|
"ResearchEngine",
|
||||||
"ResearchContext",
|
"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 { CopilotSidebar } from '@copilotkit/react-ui';
|
||||||
import { useCopilotReadable, useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
|
||||||
import '@copilotkit/react-ui/styles.css';
|
import '@copilotkit/react-ui/styles.css';
|
||||||
import './styles/alwrity-copilot.css';
|
import './styles/alwrity-copilot.css';
|
||||||
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
||||||
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
||||||
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
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 { useCopilotActions } from './components/CopilotActions';
|
||||||
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
||||||
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
||||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { saveLinkedInToAssetLibrary } from '../../services/linkedInWriterApi';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
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
|
// Optional debug flag: set to true to enable verbose logs locally
|
||||||
// const DEBUG_LINKEDIN = false;
|
// 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 {
|
interface LinkedInWriterProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -60,6 +79,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setDraft,
|
setDraft,
|
||||||
|
setChatHistory,
|
||||||
setIsPreviewing,
|
setIsPreviewing,
|
||||||
setLivePreviewHtml,
|
setLivePreviewHtml,
|
||||||
setPendingEdit,
|
setPendingEdit,
|
||||||
@@ -78,7 +98,13 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
// Utilities
|
// Utilities
|
||||||
getHistoryLength,
|
getHistoryLength,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
summarizeHistory
|
summarizeHistory,
|
||||||
|
|
||||||
|
// Direct generation methods
|
||||||
|
generatePost,
|
||||||
|
generateArticle,
|
||||||
|
generateCarousel,
|
||||||
|
generateVideoScript
|
||||||
} = useLinkedInWriter();
|
} = useLinkedInWriter();
|
||||||
|
|
||||||
// Get persona context for enhanced AI assistance
|
// Get persona context for enhanced AI assistance
|
||||||
@@ -102,6 +128,86 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
getStorageStats
|
getStorageStats
|
||||||
} = useCopilotPersistence();
|
} = 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
|
// Sync component state with enhanced persistence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[LinkedIn Writer] Component mounted, enhanced persistence enabled');
|
console.log('[LinkedIn Writer] Component mounted, enhanced persistence enabled');
|
||||||
@@ -110,22 +216,34 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
const loadPersistedData = () => {
|
const loadPersistedData = () => {
|
||||||
try {
|
try {
|
||||||
// Load chat history
|
// Load chat history
|
||||||
const chatHistory = loadChatHistory();
|
const persistedChatHistory = loadChatHistory();
|
||||||
console.log(`📖 Loaded ${chatHistory.length} persisted chat messages`);
|
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
|
// Load user preferences
|
||||||
const persistedPrefs = loadPersistedPreferences();
|
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();
|
const conversationContext = loadConversationContext();
|
||||||
console.log('📖 Loaded persisted conversation context:', conversationContext);
|
console.log('📖 Loaded persisted conversation context:', conversationContext);
|
||||||
|
|
||||||
// Load draft content
|
// Load draft content
|
||||||
const persistedDraft = loadDraftContent();
|
const persistedDraft = loadDraftContent();
|
||||||
if (persistedDraft && !draft) {
|
if (persistedDraft && !draft) {
|
||||||
console.log('📖 Restoring persisted draft content');
|
setDraft(persistedDraft);
|
||||||
// Note: We'll need to integrate this with the useLinkedInWriter hook
|
console.log('📖 Restored persisted draft content');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load last session
|
// Load last session
|
||||||
@@ -182,13 +300,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
savePersistedPreferences(prefs);
|
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
|
// Auto-save draft content when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft && draft.trim().length > 0) {
|
if (draft && draft.trim().length > 0) {
|
||||||
@@ -196,12 +307,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
}
|
}
|
||||||
}, [draft, saveDraftContent]);
|
}, [draft, saveDraftContent]);
|
||||||
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'User context and notes for LinkedIn content',
|
|
||||||
value: context,
|
|
||||||
categories: ['social', 'linkedin', 'context']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow Copilot to update the draft directly
|
// Allow Copilot to update the draft directly
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'updateLinkedInDraft',
|
name: 'updateLinkedInDraft',
|
||||||
@@ -239,105 +344,14 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
setDraft
|
setDraft
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const labels = useMemo(() => ({
|
||||||
<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={{
|
|
||||||
title: 'ALwrity Co-Pilot',
|
title: 'ALwrity Co-Pilot',
|
||||||
initial: draft ?
|
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.' :
|
? '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!`
|
: `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]);
|
||||||
suggestions={getIntelligentSuggestions}
|
|
||||||
makeSystemMessage={(context: string, additional?: string) => {
|
const makeSystemMessage = useCallback((context: string, additional?: string) => {
|
||||||
const prefs = userPreferences;
|
const prefs = userPreferences;
|
||||||
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
||||||
const history = summarizeHistory();
|
const history = summarizeHistory();
|
||||||
@@ -347,7 +361,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
const industry = prefs.industry || 'Technology';
|
const industry = prefs.industry || 'Technology';
|
||||||
const audience = prefs.target_audience || 'professionals';
|
const audience = prefs.target_audience || 'professionals';
|
||||||
|
|
||||||
// Enhanced persona-aware guidance
|
|
||||||
const personaGuidance = corePersona && platformPersona ? `
|
const personaGuidance = corePersona && platformPersona ? `
|
||||||
PERSONA-AWARE WRITING GUIDANCE:
|
PERSONA-AWARE WRITING GUIDANCE:
|
||||||
- PERSONA: ${corePersona.persona_name} (${corePersona.archetype})
|
- 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 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();
|
Always use the most appropriate tool for the user's request.`.trim();
|
||||||
return [prefsLine, historyLine, currentDraft, guidance, additional].filter(Boolean).join('\n\n');
|
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: () => {
|
{/* Header */}
|
||||||
console.log('[LinkedIn Writer] Sidebar opened');
|
<Header
|
||||||
},
|
userPreferences={userPreferences}
|
||||||
onMessageSent: (message: any) => {
|
chatHistory={chatHistory}
|
||||||
const text = typeof message === 'string' ? message : (message?.content ?? '');
|
showPreferencesModal={showPreferencesModal}
|
||||||
if (text) {
|
onPreferencesModalChange={setShowPreferencesModal}
|
||||||
console.log('[LinkedIn Writer] User message tracked:', { content_length: text.length });
|
onPreferencesChange={handlePreferencesChange}
|
||||||
}
|
onClearHistory={handleClearHistory}
|
||||||
},
|
getHistoryLength={getHistoryLength}
|
||||||
onFeedbackGiven: (id: string, type: string) => {
|
/>
|
||||||
console.log('[LinkedIn Writer] Feedback given:', { id, type });
|
|
||||||
}
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
import { showToastNotification } from '../../utils/toastNotifications';
|
||||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||||
import {
|
import {
|
||||||
mapPostType,
|
mapPostType,
|
||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
readPrefs
|
readPrefs
|
||||||
} from './utils/linkedInWriterUtils';
|
} from './utils/linkedInWriterUtils';
|
||||||
import { apiClient } from '../../api/client';
|
import { apiClient } from '../../api/client';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
|
||||||
|
|
||||||
const RegisterLinkedInActions: React.FC = () => {
|
const RegisterLinkedInActions: React.FC = () => {
|
||||||
// LinkedIn Image Generation Actions
|
// LinkedIn Image Generation Actions
|
||||||
@@ -53,7 +52,12 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'prompt', type: 'string', required: true, description: 'The image generation prompt' },
|
{ 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)' }
|
{ name: 'aspect_ratio', type: 'string', required: false, description: 'Image aspect ratio (default: 1:1)' }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
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
|
// LinkedIn Post Generation
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInPost',
|
name: 'generateLinkedInPost',
|
||||||
@@ -468,7 +520,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'topic', type: 'string', required: false },
|
{ name: 'topic', type: 'string', required: false },
|
||||||
{ name: 'industry', 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) => {
|
handler: async (args: any) => {
|
||||||
const prefs = readPrefs();
|
const prefs = readPrefs();
|
||||||
@@ -499,7 +551,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
const res = await linkedInWriterApi.generateCarousel({
|
const res = await linkedInWriterApi.generateCarousel({
|
||||||
topic: args?.topic || prefs.topic || 'Professional development tips',
|
topic: args?.topic || prefs.topic || 'Professional development tips',
|
||||||
industry: mapIndustry(args?.industry || prefs.industry),
|
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),
|
tone: mapTone(args?.tone || prefs.tone),
|
||||||
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||||
key_takeaways: args?.key_takeaways || prefs.key_takeaways || [],
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'optimizeLinkedInProfile',
|
name: 'optimizeLinkedInProfile',
|
||||||
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
||||||
@@ -907,29 +959,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'experience_level', type: 'string', required: false }
|
{ name: 'experience_level', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.optimizeProfile({
|
showToastNotification('LinkedIn Profile Optimization is coming soon! Stay tuned for this feature.', 'info');
|
||||||
current_headline: args?.current_headline || 'Professional',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LinkedIn Poll Generation
|
// LinkedIn Poll Generation (Coming Soon)
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInPoll',
|
name: 'generateLinkedInPoll',
|
||||||
description: 'Generate an engaging LinkedIn poll with professional questions',
|
description: 'Generate an engaging LinkedIn poll with professional questions',
|
||||||
@@ -938,31 +974,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'poll_type', type: 'string', required: false }
|
{ name: 'poll_type', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.generatePoll({
|
showToastNotification('LinkedIn Poll Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||||
topic: args?.topic || 'Professional development',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LinkedIn Company Update Generation
|
// LinkedIn Company Update Generation (Coming Soon)
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInCompanyUpdate',
|
name: 'generateLinkedInCompanyUpdate',
|
||||||
description: 'Generate a professional company update for LinkedIn',
|
description: 'Generate a professional company update for LinkedIn',
|
||||||
@@ -971,22 +989,9 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'update_type', type: 'string', required: false },
|
{ name: 'update_type', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.generateCompanyUpdate({
|
showToastNotification('LinkedIn Company Update Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||||
company_name: args?.company_name || 'Your Company',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
|
||||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||||
import {
|
import {
|
||||||
mapPostType,
|
mapPostType,
|
||||||
@@ -9,8 +8,7 @@ import {
|
|||||||
readPrefs
|
readPrefs
|
||||||
} from './utils/linkedInWriterUtils';
|
} from './utils/linkedInWriterUtils';
|
||||||
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
|
||||||
|
|
||||||
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||||
// Get persona context for enhanced content generation
|
// Get persona context for enhanced content generation
|
||||||
@@ -102,9 +100,8 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply persona constraints to parameters
|
// Apply persona constraints to parameters
|
||||||
const personaConstraints = platformPersona?.content_format_rules as any || {};
|
const maxLength = platformPersona?.content_format_rules?.character_limit || prefs.max_length || 2000;
|
||||||
const maxLength = personaConstraints.character_limit || prefs.max_length || 2000;
|
const optimalLength = platformPersona?.content_format_rules?.optimal_length || '150-300 words';
|
||||||
const optimalLength = personaConstraints.optimal_length || '150-300 words';
|
|
||||||
|
|
||||||
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
||||||
|
|
||||||
@@ -329,8 +326,10 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Continue with article generation...
|
// Complete progress and end loading
|
||||||
// (Implementation would continue similar to the post generation)
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -373,7 +372,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
},
|
},
|
||||||
platform_compliance: {
|
platform_compliance: {
|
||||||
character_count: content.length,
|
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',
|
status: 'analyzing',
|
||||||
suggestions: [] as string[]
|
suggestions: [] as string[]
|
||||||
}
|
}
|
||||||
@@ -401,7 +400,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Platform compliance check
|
// 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) {
|
if (content.length > charLimit) {
|
||||||
validation.platform_compliance.status = 'exceeds_limit';
|
validation.platform_compliance.status = 'exceeds_limit';
|
||||||
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
||||||
@@ -445,13 +444,13 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
const suggestions = {
|
const suggestions = {
|
||||||
writing_style: {
|
writing_style: {
|
||||||
sentence_structure: corePersona.linguistic_fingerprint?.sentence_metrics?.preferred_sentence_type || 'balanced',
|
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'
|
vocabulary_level: corePersona.linguistic_fingerprint?.lexical_features?.vocabulary_level || 'professional'
|
||||||
},
|
},
|
||||||
platform_optimization: {
|
platform_optimization: {
|
||||||
character_limit: (platformPersona.content_format_rules as any)?.character_limit || 3000,
|
character_limit: platformPersona.content_format_rules?.character_limit || 3000,
|
||||||
optimal_length: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
optimal_length: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||||
hashtag_strategy: (platformPersona.lexical_features as any)?.hashtag_strategy || '3-5 relevant hashtags'
|
hashtag_strategy: platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'
|
||||||
},
|
},
|
||||||
persona_specific: {
|
persona_specific: {
|
||||||
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
||||||
|
|||||||
@@ -1,156 +1,216 @@
|
|||||||
import React from 'react';
|
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 = () => {
|
const RegisterLinkedInEditActions: React.FC = () => {
|
||||||
// Professionalize Content
|
// ── 1. Professionalize ────────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'professionalizeLinkedInContent',
|
name: 'professionalizeLinkedInContent',
|
||||||
description: 'Make LinkedIn content more professional and industry-appropriate',
|
description: 'Make LinkedIn content more professional, polished, and industry-appropriate using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'target_audience', type: 'string', required: false }
|
{ name: 'target_audience', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
// This would integrate with a backend endpoint for content professionalization
|
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const industry = args?.industry || 'Technology';
|
if (!content.trim()) return { success: false, message: 'No content to professionalize' };
|
||||||
const targetAudience = args?.target_audience || 'Professionals';
|
|
||||||
|
|
||||||
// For now, return a placeholder response
|
const res = await linkedInWriterApi.editContent({
|
||||||
const professionalizedContent = `[Professionalized version of your content for ${industry} industry targeting ${targetAudience}]\n\n${content}`;
|
content,
|
||||||
|
edit_type: 'professionalize',
|
||||||
|
industry: args?.industry,
|
||||||
|
target_audience: args?.target_audience,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: professionalizedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: professionalizedContent };
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'optimizeLinkedInEngagement',
|
name: 'optimizeLinkedInEngagement',
|
||||||
description: 'Optimize LinkedIn content for better engagement and reach',
|
description: 'Optimize LinkedIn content for better engagement — strengthen hook, improve readability, encourage interaction',
|
||||||
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',
|
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to optimize' };
|
||||||
|
|
||||||
// Placeholder for hashtag addition
|
const res = await linkedInWriterApi.editContent({
|
||||||
const hashtags = '#ProfessionalDevelopment #Networking #IndustryInsights #CareerGrowth';
|
content,
|
||||||
const contentWithHashtags = `${content}\n\n${hashtags}`;
|
edit_type: 'optimize_engagement',
|
||||||
|
industry: args?.industry,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithHashtags } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: contentWithHashtags };
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'adjustLinkedInTone',
|
name: 'addLinkedInHashtags',
|
||||||
description: 'Adjust the tone of LinkedIn content to be more professional, conversational, or authoritative',
|
description: 'Generate relevant, industry-specific hashtags for LinkedIn content using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ 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) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const targetTone = args?.target_tone || 'professional';
|
const targetTone = args?.target_tone || 'professional';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to adjust tone for' };
|
||||||
|
|
||||||
// Placeholder for tone adjustment
|
const res = await linkedInWriterApi.editContent({
|
||||||
const adjustedContent = `[Content adjusted to ${targetTone} tone]\n\n${content}`;
|
content,
|
||||||
|
edit_type: 'adjust_tone',
|
||||||
|
tone: targetTone,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: adjustedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: adjustedContent };
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'expandLinkedInContent',
|
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: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ 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) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
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 res = await linkedInWriterApi.editContent({
|
||||||
const expandedContent = `${content}\n\n[Additional ${expansionType} and context added here]`;
|
content,
|
||||||
|
edit_type: 'expand',
|
||||||
|
industry: args?.industry,
|
||||||
|
target_audience: args?.target_audience,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: expandedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: expandedContent };
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'condenseLinkedInContent',
|
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: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ 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) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
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 lengthMap: Record<string, string> = { short: 'very concise (1-2 sentences)', medium: 'half the original length', long: 'slightly shortened' };
|
||||||
const condensedContent = `[Condensed to ${targetLength} format]\n\n${content.substring(0, Math.min(content.length, 500))}...`;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: condensedContent } }));
|
const res = await linkedInWriterApi.editContent({
|
||||||
return { success: true, content: condensedContent };
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'addLinkedInCallToAction',
|
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: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ 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) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
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 = {
|
if (/\b(call now|sign up|join|try|learn more|comment|share|connect|message|dm|reach out)\b/i.test(content)) {
|
||||||
engagement: 'What are your thoughts on this? Share your experience in the comments below!',
|
showToastNotification('Content already contains a call to action.', 'info');
|
||||||
networking: 'Let\'s connect if you\'re interested in discussing this further.',
|
return { success: false, message: 'Content already has a CTA' };
|
||||||
learning: 'Would you like to learn more about this topic? Drop a comment or DM me.',
|
}
|
||||||
collaboration: 'Interested in collaborating on similar projects? Let\'s connect!'
|
|
||||||
};
|
|
||||||
|
|
||||||
const cta = ctaOptions[ctaType as keyof typeof ctaOptions] || ctaOptions.engagement;
|
const res = await linkedInWriterApi.editContent({
|
||||||
const contentWithCTA = `${content}\n\n${cta}`;
|
content,
|
||||||
|
edit_type: 'add_cta',
|
||||||
|
parameters: { cta_type: args?.cta_type || 'engagement' },
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithCTA } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: contentWithCTA };
|
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 React, { useCallback, useMemo } from 'react';
|
||||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
import { apiClient } from '../../../api/client';
|
import { apiClient } from '../../../api/client';
|
||||||
|
import '../../../types/linkedinWriterEvents';
|
||||||
|
|
||||||
// Define the cache data type
|
// Define the cache data type
|
||||||
interface BrainstormCacheData {
|
interface BrainstormCacheData {
|
||||||
@@ -118,7 +119,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
const handler = async (ev: any) => {
|
const handler = async (ev: any) => {
|
||||||
try {
|
try {
|
||||||
// Store the event for refresh functionality
|
// Store the event for refresh functionality
|
||||||
(window as any).lastBrainstormEvent = ev;
|
window.lastBrainstormEvent = ev;
|
||||||
|
|
||||||
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
||||||
const finalSeed = ideaSeed || prompt;
|
const finalSeed = ideaSeed || prompt;
|
||||||
@@ -239,8 +240,8 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
setBrainstormVisible(false);
|
setBrainstormVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||||
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,7 +276,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Force refresh by clearing cache and re-running
|
// 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) {
|
if (prompt || ideaSeed) {
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||||
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
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 ?? '',
|
target_audience: args.target_audience ?? prefs.target_audience ?? '',
|
||||||
tone: args.tone ?? prefs.tone ?? 'professional',
|
tone: args.tone ?? prefs.tone ?? 'professional',
|
||||||
industry: args.industry ?? prefs.industry ?? 'technology',
|
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 ?? []),
|
key_takeaways: args.key_takeaways ?? (prefs.key_takeaways ?? []),
|
||||||
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||||
include_cta_slide: args.include_cta_slide ?? (prefs.include_cta_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', {
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
detail: {
|
detail: {
|
||||||
action: 'Generating LinkedIn Carousel',
|
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,
|
target_audience: form.target_audience,
|
||||||
tone: mapTone(form.tone),
|
tone: mapTone(form.tone),
|
||||||
industry: mapIndustry(form.industry),
|
industry: mapIndustry(form.industry),
|
||||||
slide_count: form.slide_count,
|
number_of_slides: form.number_of_slides,
|
||||||
key_takeaways: form.key_takeaways,
|
key_takeaways: form.key_takeaways,
|
||||||
include_cover_slide: form.include_cover_slide,
|
include_cover_slide: form.include_cover_slide,
|
||||||
include_cta_slide: form.include_cta_slide,
|
include_cta_slide: form.include_cta_slide,
|
||||||
@@ -73,7 +73,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
tone: form.tone,
|
tone: form.tone,
|
||||||
industry: form.industry,
|
industry: form.industry,
|
||||||
target_audience: form.target_audience,
|
target_audience: form.target_audience,
|
||||||
slide_count: form.slide_count,
|
number_of_slides: form.number_of_slides,
|
||||||
key_takeaways: form.key_takeaways,
|
key_takeaways: form.key_takeaways,
|
||||||
include_cover_slide: form.include_cover_slide,
|
include_cover_slide: form.include_cover_slide,
|
||||||
include_cta_slide: form.include_cta_slide,
|
include_cta_slide: form.include_cta_slide,
|
||||||
@@ -100,7 +100,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
success: true,
|
success: true,
|
||||||
carousel_content: content,
|
carousel_content: content,
|
||||||
title: res.data.title,
|
title: res.data.title,
|
||||||
slide_count: res.data.slides.length
|
number_of_slides: res.data.slides.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No data received from API');
|
throw new Error('No data received from API');
|
||||||
@@ -183,11 +183,11 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="slide_count">Number of Slides</label>
|
<label htmlFor="number_of_slides">Number of Slides</label>
|
||||||
<select
|
<select
|
||||||
id="slide_count"
|
id="number_of_slides"
|
||||||
value={form.slide_count}
|
value={form.number_of_slides}
|
||||||
onChange={(e) => setForm({ ...form, slide_count: parseInt(e.target.value) })}
|
onChange={(e) => setForm({ ...form, number_of_slides: parseInt(e.target.value) })}
|
||||||
>
|
>
|
||||||
<option value={3}>3 slides (Quick overview)</option>
|
<option value={3}>3 slides (Quick overview)</option>
|
||||||
<option value={5}>5 slides (Standard)</option>
|
<option value={5}>5 slides (Standard)</option>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
import { useCopilotContext } from '@copilotkit/react-core';
|
||||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { showToastNotification } from '../../../utils/toastNotifications';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||||
|
import '../../../types/linkedinWriterEvents';
|
||||||
|
|
||||||
// Optional debug flag: set to true to enable verbose logs locally
|
// Optional debug flag: set to true to enable verbose logs locally
|
||||||
const DEBUG_LINKEDIN = false;
|
const DEBUG_LINKEDIN = false;
|
||||||
@@ -66,9 +67,9 @@ export const useCopilotActions = ({
|
|||||||
if (copilotContext && typeof copilotContext === 'object') {
|
if (copilotContext && typeof copilotContext === 'object') {
|
||||||
try {
|
try {
|
||||||
// Check if context has any message sending capabilities
|
// 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(() => {
|
setTimeout(() => {
|
||||||
(copilotContext as any).sendMessage(prompt);
|
(copilotContext as { sendMessage: (msg: string) => void }).sendMessage(prompt);
|
||||||
console.log('Message sent via context');
|
console.log('Message sent via context');
|
||||||
return;
|
return;
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -85,7 +86,7 @@ export const useCopilotActions = ({
|
|||||||
document.querySelector('button[title*="generateFromPrompt"]');
|
document.querySelector('button[title*="generateFromPrompt"]');
|
||||||
if (actionButton) {
|
if (actionButton) {
|
||||||
// Set the prompt in a temporary storage for the action to pick up
|
// 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();
|
(actionButton as HTMLElement).click();
|
||||||
console.log('Triggered generateFromPrompt action with:', prompt);
|
console.log('Triggered generateFromPrompt action with:', prompt);
|
||||||
return;
|
return;
|
||||||
@@ -235,8 +236,8 @@ export const useCopilotActions = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
window.addEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Allow external prompts to trigger content generation
|
// Allow external prompts to trigger content generation
|
||||||
@@ -248,15 +249,15 @@ export const useCopilotActions = ({
|
|||||||
],
|
],
|
||||||
handler: async ({ prompt }: { prompt: string }) => {
|
handler: async ({ prompt }: { prompt: string }) => {
|
||||||
// Check for temporary prompt from brainstorm flow
|
// Check for temporary prompt from brainstorm flow
|
||||||
const finalPrompt = prompt || (window as any).tempPromptForGeneration;
|
const finalPrompt = prompt || window.tempPromptForGeneration;
|
||||||
|
|
||||||
if (!finalPrompt) {
|
if (!finalPrompt) {
|
||||||
return { success: false, message: 'No prompt provided' };
|
return { success: false, message: 'No prompt provided' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the temporary prompt
|
// Clear the temporary prompt
|
||||||
if ((window as any).tempPromptForGeneration) {
|
if (window.tempPromptForGeneration) {
|
||||||
delete (window as any).tempPromptForGeneration;
|
delete window.tempPromptForGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the prompt as context and trigger generation
|
// Set the prompt as context and trigger generation
|
||||||
@@ -281,9 +282,21 @@ export const useCopilotActions = ({
|
|||||||
name: 'editLinkedInDraft',
|
name: 'editLinkedInDraft',
|
||||||
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
||||||
parameters: [
|
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 }) => {
|
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 || '';
|
const currentDraft = draft || '';
|
||||||
if (!currentDraft) {
|
if (!currentDraft) {
|
||||||
return { success: false, message: 'No draft content to edit' };
|
return { success: false, message: 'No draft content to edit' };
|
||||||
@@ -336,6 +349,49 @@ export const useCopilotActions = ({
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
return { success: false, message: 'Unknown operation' };
|
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 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 hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
||||||
const isLong = (draft || '').length > 500;
|
const isLong = (draft || '').length > 500;
|
||||||
|
const hasPersona = !!(corePersona && platformPersona);
|
||||||
|
|
||||||
// Debug logging for suggestions
|
// Debug logging for suggestions
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
||||||
hasContent,
|
hasContent,
|
||||||
justGeneratedContent,
|
justGeneratedContent,
|
||||||
|
hasPersona,
|
||||||
draftLength: draft?.length || 0
|
draftLength: draft?.length || 0
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasContent) {
|
if (!hasContent) {
|
||||||
// Initial suggestions for content creation
|
// 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: '📝 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: '📄 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: '🎠 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: '🎬 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: '💬 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: '🖼️ 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.' }
|
);
|
||||||
];
|
|
||||||
|
// 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);
|
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
||||||
return initialSuggestions;
|
return initialSuggestions;
|
||||||
} else {
|
} else {
|
||||||
// Refinement suggestions for existing content - use direct edit actions
|
// 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 casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
||||||
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
||||||
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
||||||
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
{ 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: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
||||||
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
{ 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' });
|
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
|
// Add image generation suggestion when there's content
|
||||||
if (draft && draft.trim().length > 0) {
|
if (draft && draft.trim().length > 0) {
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
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);
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
||||||
return refinementSuggestions;
|
return refinementSuggestions;
|
||||||
}
|
}
|
||||||
}, [draft, justGeneratedContent]);
|
}, [draft, justGeneratedContent, corePersona, platformPersona]);
|
||||||
|
|
||||||
// Return the suggestions function directly
|
// Return the suggestions function directly
|
||||||
return getIntelligentSuggestions;
|
return getIntelligentSuggestions;
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
||||||
if (!useGoogleSearch || words.length < 4) return;
|
if (!useGoogleSearch || words.length < 4) return;
|
||||||
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
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 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 platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
||||||
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
|
||||||
import {
|
import {
|
||||||
AutoAwesome as SparklesIcon,
|
AutoAwesome as SparklesIcon,
|
||||||
PhotoCamera as PhotoIcon,
|
PhotoCamera as PhotoIcon,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
CheckCircle as CheckCircleIcon,
|
CheckCircle as CheckCircleIcon,
|
||||||
Warning as ExclamationTriangleIcon
|
Warning as ExclamationTriangleIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||||
|
|
||||||
interface ImageGenerationSuggestionsProps {
|
interface ImageGenerationSuggestionsProps {
|
||||||
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
||||||
@@ -51,9 +51,6 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
|||||||
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
||||||
const [showPrompts, setShowPrompts] = useState(false);
|
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
|
// Register Copilot action for generating image prompts
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generate_image_prompts',
|
name: 'generate_image_prompts',
|
||||||
@@ -119,7 +116,12 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
|||||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'prompt', type: 'string', required: true },
|
{ 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 }
|
{ name: 'aspect_ratio', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
||||||
|
|
||||||
interface ProgressStep {
|
export interface ProgressStep {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: ProgressStatus;
|
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 React, { useState } from 'react';
|
||||||
import { FeatureCarousel } from './FeatureCarousel';
|
import { FeatureCarousel } from './FeatureCarousel';
|
||||||
import { InfoModals } from './InfoModals';
|
import { InfoModals } from './InfoModals';
|
||||||
|
import { QuickCreate } from './QuickCreate';
|
||||||
|
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||||
|
|
||||||
interface WelcomeMessageProps {
|
interface WelcomeMessageProps {
|
||||||
draft: string;
|
draft: string;
|
||||||
isGenerating: boolean;
|
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> = ({
|
export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||||
draft,
|
draft,
|
||||||
isGenerating
|
isGenerating,
|
||||||
|
onGeneratePost,
|
||||||
|
onGenerateArticle,
|
||||||
|
onGenerateCarousel,
|
||||||
|
onGenerateVideoScript,
|
||||||
|
userPreferences
|
||||||
}) => {
|
}) => {
|
||||||
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
||||||
const [showAssistiveModal, setShowAssistiveModal] = 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.
|
Choose your preferred AI assistance mode to get started with content creation.
|
||||||
</p>
|
</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 */}
|
{/* Info Modals */}
|
||||||
<InfoModals
|
<InfoModals
|
||||||
showCopilotModal={showCopilotModal}
|
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
|
// New refactored components
|
||||||
export { Header } from './Header';
|
export { Header } from './Header';
|
||||||
export { ContentEditor } from './ContentEditor';
|
export { ContentEditor } from './ContentEditor';
|
||||||
@@ -12,6 +6,7 @@ export { WelcomeMessage } from './WelcomeMessage';
|
|||||||
export { FeatureCarousel } from './FeatureCarousel';
|
export { FeatureCarousel } from './FeatureCarousel';
|
||||||
export { InfoModals } from './InfoModals';
|
export { InfoModals } from './InfoModals';
|
||||||
export { ProgressTracker } from './ProgressTracker';
|
export { ProgressTracker } from './ProgressTracker';
|
||||||
|
export type { ProgressStep } from './ProgressTracker';
|
||||||
export { ContentRecommendations } from './ContentRecommendations';
|
export { ContentRecommendations } from './ContentRecommendations';
|
||||||
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
||||||
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
||||||
@@ -27,3 +22,4 @@ export { default as ImageGenerationTest } from './ImageGenerationTest';
|
|||||||
// Refactored Components
|
// Refactored Components
|
||||||
export { default as BrainstormFlow } from './BrainstormFlow';
|
export { default as BrainstormFlow } from './BrainstormFlow';
|
||||||
export { useCopilotActions } from './CopilotActions';
|
export { useCopilotActions } from './CopilotActions';
|
||||||
|
export { QuickCreate } from './QuickCreate';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useCopilotReadable } from '@copilotkit/react-core';
|
|
||||||
import {
|
import {
|
||||||
loadHistory,
|
loadHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
@@ -12,7 +11,8 @@ import {
|
|||||||
type ChatMsg,
|
type ChatMsg,
|
||||||
type LinkedInPreferences
|
type LinkedInPreferences
|
||||||
} from '../utils/storageUtils';
|
} 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() {
|
export function useLinkedInWriter() {
|
||||||
// Core state
|
// Core state
|
||||||
@@ -51,24 +51,18 @@ export function useLinkedInWriter() {
|
|||||||
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
||||||
|
|
||||||
// UI state
|
// 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 [showContextPanel, setShowContextPanel] = useState(false);
|
||||||
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
||||||
const [showContextModal, setShowContextModal] = useState(false);
|
const [showContextModal, setShowContextModal] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [justGeneratedContent, setJustGeneratedContent] = 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
|
// Track action usage and update preferences
|
||||||
const trackActionUsage = useCallback((actionName: string) => {
|
const trackActionUsage = useCallback((actionName: string) => {
|
||||||
const currentPrefs = getPreferences();
|
const currentPrefs = getPreferences();
|
||||||
@@ -82,10 +76,278 @@ export function useLinkedInWriter() {
|
|||||||
// Reset the flag after 30 seconds
|
// Reset the flag after 30 seconds
|
||||||
setTimeout(() => setJustGeneratedContent(false), 30000);
|
setTimeout(() => setJustGeneratedContent(false), 30000);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Update suggestions after action usage
|
// ── Direct generation methods (UI-driven, no CopilotKit dependency) ──────────
|
||||||
setTimeout(() => updateSuggestions(), 100);
|
const generatePost = useCallback(async (params?: any) => {
|
||||||
}, [updateSuggestions]);
|
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
|
// Initialize chat history and preferences from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,11 +491,6 @@ export function useLinkedInWriter() {
|
|||||||
}
|
}
|
||||||
}, [context]);
|
}, [context]);
|
||||||
|
|
||||||
// Update suggestions when relevant state changes
|
|
||||||
useEffect(() => {
|
|
||||||
updateSuggestions();
|
|
||||||
}, [updateSuggestions]);
|
|
||||||
|
|
||||||
// Handle draft updates from CopilotKit actions
|
// Handle draft updates from CopilotKit actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUpdateDraft = (event: CustomEvent) => {
|
const handleUpdateDraft = (event: CustomEvent) => {
|
||||||
@@ -246,9 +503,7 @@ export function useLinkedInWriter() {
|
|||||||
setCurrentAction(null);
|
setCurrentAction(null);
|
||||||
// Auto-show preview when new content is generated
|
// Auto-show preview when new content is generated
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
// Hide progress tracker when content is generated
|
// Progress is finalized by the progressStep/progressComplete events dispatched after this
|
||||||
setProgressActive(false);
|
|
||||||
setProgressSteps([]);
|
|
||||||
console.log('[LinkedIn Writer] Draft update complete');
|
console.log('[LinkedIn Writer] Draft update complete');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,22 +595,6 @@ export function useLinkedInWriter() {
|
|||||||
console.log('[LinkedIn Writer] Chat memory cleared by user');
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
draft,
|
draft,
|
||||||
@@ -403,11 +642,16 @@ export function useLinkedInWriter() {
|
|||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
trackActionUsage,
|
trackActionUsage,
|
||||||
updateSuggestions,
|
|
||||||
getHistoryLength,
|
getHistoryLength,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
summarizeHistory,
|
summarizeHistory,
|
||||||
|
|
||||||
|
// Direct generation methods
|
||||||
|
generatePost,
|
||||||
|
generateArticle,
|
||||||
|
generateCarousel,
|
||||||
|
generateVideoScript,
|
||||||
|
|
||||||
// Grounding data
|
// Grounding data
|
||||||
researchSources,
|
researchSources,
|
||||||
citations,
|
citations,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export const VALID_TONES = [
|
|||||||
|
|
||||||
export const VALID_SEARCH_ENGINES = [
|
export const VALID_SEARCH_ENGINES = [
|
||||||
'google',
|
'google',
|
||||||
'tavily'
|
'tavily',
|
||||||
|
'exa'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const VALID_INDUSTRIES = [
|
export const VALID_INDUSTRIES = [
|
||||||
@@ -157,21 +158,17 @@ export function mapIndustry(industry: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapSearchEngine(engine: string | undefined): SearchEngine {
|
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);
|
const eng = normalizeEnum(engine);
|
||||||
if (!eng) return SearchEngine.GOOGLE;
|
if (!eng) return SearchEngine.EXA;
|
||||||
|
|
||||||
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
||||||
if (exact) return exact as SearchEngine;
|
if (exact) return exact as SearchEngine;
|
||||||
|
|
||||||
|
if (eng.includes('exa')) return SearchEngine.EXA;
|
||||||
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
||||||
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
||||||
|
|
||||||
return SearchEngine.GOOGLE;
|
return SearchEngine.EXA;
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapResponseType(responseType: string | undefined): string {
|
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 {
|
export enum SearchEngine {
|
||||||
GOOGLE = 'google',
|
GOOGLE = 'google',
|
||||||
TAVILY = 'tavily'
|
TAVILY = 'tavily',
|
||||||
|
EXA = 'exa'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GroundingLevel {
|
export enum GroundingLevel {
|
||||||
@@ -66,7 +67,7 @@ export interface LinkedInArticleRequest {
|
|||||||
export interface LinkedInCarouselRequest {
|
export interface LinkedInCarouselRequest {
|
||||||
topic: string;
|
topic: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
slide_count?: number;
|
number_of_slides?: number;
|
||||||
tone?: LinkedInTone;
|
tone?: LinkedInTone;
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
key_takeaways?: string[];
|
key_takeaways?: string[];
|
||||||
@@ -238,6 +239,24 @@ export interface LinkedInCommentResponseResult {
|
|||||||
error?: string;
|
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
|
// API client
|
||||||
export const linkedInWriterApi = {
|
export const linkedInWriterApi = {
|
||||||
async health(): Promise<any> {
|
async health(): Promise<any> {
|
||||||
@@ -270,18 +289,64 @@ export const linkedInWriterApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async optimizeProfile(request: any): Promise<any> {
|
async editContent(request: LinkedInEditContentRequest): Promise<LinkedInEditContentResponse> {
|
||||||
const { data } = await apiClient.post('/api/linkedin/optimize-profile', request);
|
const { data } = await aiApiClient.post('/api/linkedin/edit-content', 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);
|
|
||||||
return data;
|
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