Added image generation to blog writer

This commit is contained in:
ajaysi
2025-10-31 15:59:16 +05:30
parent 3219e6bbe4
commit cdb41aec1b
80 changed files with 7662 additions and 3951 deletions

View File

@@ -7,6 +7,7 @@ content creation, SEO analysis, and publishing.
from fastapi import APIRouter, HTTPException
from typing import Any, Dict, List
from pydantic import BaseModel, Field
from loguru import logger
from models.blog_models import (
@@ -29,6 +30,7 @@ from models.blog_models import (
HallucinationCheckResponse,
)
from services.blog_writer.blog_service import BlogWriterService
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
from .task_manager import task_manager
from .cache_manager import cache_manager
from models.blog_models import MediumBlogGenerateRequest
@@ -37,6 +39,44 @@ from models.blog_models import MediumBlogGenerateRequest
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
service = BlogWriterService()
recommendation_applier = BlogSEORecommendationApplier()
# ---------------------------
# SEO Recommendation Endpoints
# ---------------------------
class RecommendationItem(BaseModel):
category: str = Field(..., description="Recommendation category, e.g. Structure")
priority: str = Field(..., description="Priority level: High | Medium | Low")
recommendation: str = Field(..., description="Action to perform")
impact: str = Field(..., description="Expected impact or rationale")
class SEOApplyRecommendationsRequest(BaseModel):
title: str = Field(..., description="Current blog title")
sections: List[Dict[str, Any]] = Field(..., description="Array of sections with id, heading, content")
outline: List[Dict[str, Any]] = Field(default_factory=list, description="Outline structure for context")
research: Dict[str, Any] = Field(default_factory=dict, description="Research data used for the blog")
recommendations: List[RecommendationItem] = Field(..., description="Actionable recommendations to apply")
persona: Dict[str, Any] = Field(default_factory=dict, description="Persona settings if available")
tone: str | None = Field(default=None, description="Desired tone override")
audience: str | None = Field(default=None, description="Target audience override")
@router.post("/seo/apply-recommendations")
async def apply_seo_recommendations(request: SEOApplyRecommendationsRequest) -> Dict[str, Any]:
"""Apply actionable SEO recommendations and return updated content."""
try:
result = await recommendation_applier.apply_recommendations(request.dict())
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to apply recommendations"))
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to apply SEO recommendations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
@@ -92,7 +132,7 @@ async def start_outline_generation(request: BlogOutlineRequest) -> Dict[str, Any
async def get_outline_status(task_id: str) -> Dict[str, Any]:
"""Get the status of an outline generation operation."""
try:
status = task_manager.get_task_status(task_id)
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
@@ -164,6 +204,50 @@ async def generate_section(request: BlogSectionRequest) -> BlogSectionResponse:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/content/start")
async def start_content_generation(request: Dict[str, Any]) -> Dict[str, Any]:
"""Start full content generation and return a task id for polling.
Accepts a payload compatible with MediumBlogGenerateRequest to minimize duplication.
"""
try:
# Map dict to MediumBlogGenerateRequest for reuse
from models.blog_models import MediumBlogGenerateRequest, MediumSectionOutline, PersonaInfo
sections = [MediumSectionOutline(**s) for s in request.get("sections", [])]
persona = None
if request.get("persona"):
persona = PersonaInfo(**request.get("persona"))
req = MediumBlogGenerateRequest(
title=request.get("title", "Untitled Blog"),
sections=sections,
persona=persona,
tone=request.get("tone"),
audience=request.get("audience"),
globalTargetWords=request.get("globalTargetWords", 1000),
researchKeywords=request.get("researchKeywords") or request.get("keywords"),
)
task_id = task_manager.start_content_generation_task(req)
return {"task_id": task_id, "status": "started"}
except Exception as e:
logger.error(f"Failed to start content generation: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/content/status/{task_id}")
async def content_generation_status(task_id: str) -> Dict[str, Any]:
"""Poll status for content generation task."""
try:
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get content generation status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/section/{section_id}/continuity")
async def get_section_continuity(section_id: str) -> Dict[str, Any]:
"""Fetch last computed continuity metrics for a section (if available)."""
@@ -342,7 +426,7 @@ async def start_medium_generation(request: MediumBlogGenerateRequest):
async def medium_generation_status(task_id: str):
"""Poll status for medium blog generation task."""
try:
status = task_manager.get_task_status(task_id)
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status
@@ -366,7 +450,7 @@ async def start_blog_rewrite(request: Dict[str, Any]) -> Dict[str, Any]:
async def rewrite_status(task_id: str):
"""Poll status for blog rewrite task."""
try:
status = service.task_manager.get_task_status(task_id)
status = await service.task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status

View File

@@ -133,6 +133,16 @@ class TaskManager:
task_id = self.create_task("medium_generation")
asyncio.create_task(self._run_medium_generation_task(task_id, request))
return task_id
def start_content_generation_task(self, request: MediumBlogGenerateRequest) -> str:
"""Start content generation (full blog via sections) with provider parity.
Internally reuses medium generator pipeline for now but tracked under
distinct task_type 'content_generation' and same polling contract.
"""
task_id = self.create_task("content_generation")
asyncio.create_task(self._run_medium_generation_task(task_id, request))
return task_id
async def _run_research_task(self, task_id: str, request: BlogResearchRequest):
"""Background task to run research and update status with progress messages."""

View File

@@ -4,11 +4,11 @@ from typing import Dict, Any, List
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
from .base_service import FacebookWriterBaseService
try:
from ...services.llm_providers.text_to_image_generation.gen_gemini_images import (
generate_gemini_images_base64,
)
from ...services.llm_providers.main_image_generation import generate_image
from base64 import b64encode
except Exception:
generate_gemini_images_base64 = None # type: ignore
generate_image = None # type: ignore
b64encode = None # type: ignore
class FacebookStoryService(FacebookWriterBaseService):
@@ -50,22 +50,29 @@ class FacebookStoryService(FacebookWriterBaseService):
# Generate visual suggestions and engagement tips
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
engagement_tips = self._generate_engagement_tips("story")
# Optional: generate one story image (9:16) using Gemini
# Optional: generate one story image (9:16) using unified image generation
images_base64: List[str] = []
try:
if generate_gemini_images_base64 is not None:
if generate_image is not None and b64encode is not None:
img_prompt = request.visual_options.background_image_prompt or (
f"Facebook story background for {request.business_type}. "
f"Style: {actual_tone}. Type: {actual_story_type}. Vertical mobile 9:16, high contrast, legible overlay space."
)
images_base64 = generate_gemini_images_base64(
img_prompt,
enhance_prompt=False,
aspect_ratio="9:16",
max_retries=2,
initial_retry_delay=1.0,
) or []
except Exception:
# Generate image using unified system (9:16 aspect ratio = 1080x1920)
result = generate_image(
prompt=img_prompt,
options={
"provider": "gemini", # Facebook stories use Gemini
"width": 1080,
"height": 1920,
}
)
if result and result.image_bytes:
# Convert bytes to base64
image_b64 = b64encode(result.image_bytes).decode('utf-8')
images_base64 = [image_b64]
except Exception as e:
# Log error but continue without images
images_base64 = []
return FacebookStoryResponse(

217
backend/api/images.py Normal file
View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import base64
import os
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from services.llm_providers.main_image_generation import generate_image
from services.llm_providers.main_text_generation import llm_text_gen
from utils.logger_utils import get_service_logger
router = APIRouter(prefix="/api/images", tags=["images"])
logger = get_service_logger("api.images")
class ImageGenerateRequest(BaseModel):
prompt: str
negative_prompt: Optional[str] = None
provider: Optional[str] = Field(None, pattern="^(gemini|huggingface|stability)$")
model: Optional[str] = None
width: Optional[int] = Field(default=1024, ge=64, le=2048)
height: Optional[int] = Field(default=1024, ge=64, le=2048)
guidance_scale: Optional[float] = None
steps: Optional[int] = None
seed: Optional[int] = None
class ImageGenerateResponse(BaseModel):
success: bool = True
image_base64: str
width: int
height: int
provider: str
model: Optional[str] = None
seed: Optional[int] = None
@router.post("/generate", response_model=ImageGenerateResponse)
def generate(req: ImageGenerateRequest) -> ImageGenerateResponse:
try:
last_error: Optional[Exception] = None
for attempt in range(2): # simple single retry
try:
result = generate_image(
prompt=req.prompt,
options={
"negative_prompt": req.negative_prompt,
"provider": req.provider,
"model": req.model,
"width": req.width,
"height": req.height,
"guidance_scale": req.guidance_scale,
"steps": req.steps,
"seed": req.seed,
},
)
image_b64 = base64.b64encode(result.image_bytes).decode("utf-8")
return ImageGenerateResponse(
image_base64=image_b64,
width=result.width,
height=result.height,
provider=result.provider,
model=result.model,
seed=result.seed,
)
except Exception as inner:
last_error = inner
logger.error(f"Image generation attempt {attempt+1} failed: {inner}")
# On first failure, try provider auto-remap by clearing provider to let facade decide
if attempt == 0 and req.provider:
req.provider = None
continue
break
raise last_error or RuntimeError("Unknown image generation error")
except Exception as e:
logger.error(f"Image generation failed: {e}")
# Provide a clean, actionable message to the client
raise HTTPException(
status_code=500,
detail="Image generation service is temporarily unavailable or the connection was reset. Please try again."
)
class PromptSuggestion(BaseModel):
prompt: str
negative_prompt: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
overlay_text: Optional[str] = None
class ImagePromptSuggestRequest(BaseModel):
provider: Optional[str] = Field(None, pattern="^(gemini|huggingface|stability)$")
title: Optional[str] = None
section: Optional[Dict[str, Any]] = None
research: Optional[Dict[str, Any]] = None
persona: Optional[Dict[str, Any]] = None
include_overlay: Optional[bool] = True
class ImagePromptSuggestResponse(BaseModel):
suggestions: list[PromptSuggestion]
@router.post("/suggest-prompts", response_model=ImagePromptSuggestResponse)
def suggest_prompts(req: ImagePromptSuggestRequest) -> ImagePromptSuggestResponse:
try:
provider = (req.provider or ("gemini" if (os.getenv("GPT_PROVIDER") or "").lower().startswith("gemini") else "huggingface")).lower()
section = req.section or {}
title = (req.title or section.get("heading") or "").strip()
subheads = section.get("subheadings", []) or []
key_points = section.get("key_points", []) or []
keywords = section.get("keywords", []) or []
if not keywords and req.research:
keywords = (
req.research.get("keywords", {}).get("primary_keywords")
or req.research.get("keywords", {}).get("primary")
or []
)
persona = req.persona or {}
audience = persona.get("audience", "content creators and digital marketers")
industry = persona.get("industry", req.research.get("domain") if req.research else "your industry")
tone = persona.get("tone", "professional, trustworthy")
schema = {
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"negative_prompt": {"type": "string"},
"width": {"type": "number"},
"height": {"type": "number"},
"overlay_text": {"type": "string"},
},
"required": ["prompt"]
},
"minItems": 3,
"maxItems": 5
}
},
"required": ["suggestions"]
}
system = (
"You are an expert image prompt engineer for text-to-image models. "
"Given blog section context, craft 3-5 hyper-personalized prompts optimized for the specified provider. "
"Return STRICT JSON matching the provided schema, no extra text."
)
provider_guidance = {
"huggingface": "Photorealistic Flux 1 Krea Dev; include camera/lighting cues (e.g., 50mm, f/2.8, rim light).",
"gemini": "Editorial, brand-safe, crisp edges, balanced lighting; avoid artifacts.",
"stability": "SDXL coherent details, sharp focus, cinematic contrast; readable text if present."
}.get(provider, "")
best_practices = (
"Best Practices: one clear focal subject; clean, uncluttered background; rule-of-thirds or center-weighted composition; "
"text-safe margins if overlay text is included; neutral lighting if unsure; realistic skin tones; avoid busy patterns; "
"no brand logos or watermarks; no copyrighted characters; avoid low-res, blur, noise, banding, oversaturation, over-sharpening; "
"ensure hands and text are coherent if present; prefer 1024px+ on shortest side for quality."
)
# Harvest a few concise facts from research if available
facts: list[str] = []
try:
if req.research:
# try common shapes used in research service
top_stats = req.research.get("key_facts") or req.research.get("highlights") or []
if isinstance(top_stats, list):
facts = [str(x) for x in top_stats[:3]]
elif isinstance(top_stats, dict):
facts = [f"{k}: {v}" for k, v in list(top_stats.items())[:3]]
except Exception:
facts = []
facts_line = ", ".join(facts) if facts else ""
overlay_hint = "Include an on-image short title or fact if it improves communication; ensure clean, high-contrast safe area for text." if (req.include_overlay is None or req.include_overlay) else "Do not include on-image text."
prompt = f"""
Provider: {provider}
Title: {title}
Subheadings: {', '.join(subheads[:5])}
Key Points: {', '.join(key_points[:5])}
Keywords: {', '.join([str(k) for k in keywords[:8]])}
Research Facts: {facts_line}
Audience: {audience}
Industry: {industry}
Tone: {tone}
Craft prompts that visually reflect this exact section (not generic blog topic). {provider_guidance}
{best_practices}
{overlay_hint}
Include a suitable negative_prompt where helpful. Suggest width/height when relevant (e.g., 1024x1024 or 1920x1080).
If including on-image text, return it in overlay_text (short: <= 8 words).
"""
raw = llm_text_gen(prompt=prompt, system_prompt=system, json_struct=schema)
data = raw if isinstance(raw, dict) else {}
suggestions = data.get("suggestions") or []
# basic fallback if provider returns string
if not suggestions and isinstance(raw, str):
suggestions = [{"prompt": raw}]
return ImagePromptSuggestResponse(suggestions=[PromptSuggestion(**s) for s in suggestions])
except Exception as e:
logger.error(f"Prompt suggestion failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -42,6 +42,7 @@ from routers.linkedin import router as linkedin_router
# Import LinkedIn image generation router
from api.linkedin_image_generation import router as linkedin_image_router
from api.brainstorm import router as brainstorm_router
from api.images import router as images_router
# Import hallucination detector router
from api.hallucination_detector import router as hallucination_detector_router
@@ -279,6 +280,7 @@ async def batch_analyze_urls_endpoint(urls: list[str]):
# Include platform analytics router
from routers.platform_analytics import router as platform_analytics_router
app.include_router(platform_analytics_router)
app.include_router(images_router)
# Setup frontend serving using modular utilities
frontend_serving.setup_frontend_serving()

View File

@@ -186,6 +186,8 @@ class BlogSEOMetadataRequest(BaseModel):
title: Optional[str] = None
keywords: List[str] = []
research_data: Optional[Dict[str, Any]] = None
outline: Optional[List[Dict[str, Any]]] = None # Add outline structure
seo_analysis: Optional[Dict[str, Any]] = None # Add SEO analysis results
class BlogSEOMetadataResponse(BaseModel):

View File

@@ -21,10 +21,9 @@ httpx>=0.27.2,<0.28.0
# AI/ML dependencies
openai>=1.3.0
anthropic>=0.7.0
mistralai>=0.0.12
google-genai>=1.0.0
google-ai-generativelanguage>=0.6.18,<0.7.0
google-api-python-client>=2.100.0
google-auth>=2.23.0
google-auth-oauthlib>=1.0.0
@@ -53,6 +52,7 @@ nltk>=3.8.0
# Image and audio processing for Stability AI
Pillow>=10.0.0
huggingface_hub>=0.24.0
scikit-learn>=1.3.0
# Testing dependencies

View File

@@ -1,12 +1,14 @@
"""
EnhancedContentGenerator - thin orchestrator combining URL selection and Gemini provider.
EnhancedContentGenerator - thin orchestrator for section generation.
Provides Draft vs Polished modes and optional URL Context usage.
Provider parity:
- Uses main_text_generation.llm_text_gen to respect GPT_PROVIDER (Gemini/HF)
- No direct provider coupling here; Google grounding remains in research only
"""
from typing import Any, Dict
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
from services.llm_providers.main_text_generation import llm_text_gen
from .source_url_manager import SourceURLManager
from .context_memory import ContextMemory
from .transition_generator import TransitionGenerator
@@ -15,24 +17,37 @@ from .flow_analyzer import FlowAnalyzer
class EnhancedContentGenerator:
def __init__(self):
self.provider = GeminiGroundedProvider()
self.url_manager = SourceURLManager()
self.memory = ContextMemory(max_entries=12)
self.transitioner = TransitionGenerator()
self.flow = FlowAnalyzer()
async def generate_section(self, section: Any, research: Any, mode: str = "polished") -> Dict[str, Any]:
urls = self.url_manager.pick_relevant_urls(section, research)
prev_summary = self.memory.build_previous_sections_summary(limit=2)
prompt = self._build_prompt(section, research, prev_summary)
result = await self.provider.generate_grounded_content(
prompt=prompt,
content_type="linkedin_article",
temperature=0.6 if mode == "polished" else 0.8,
max_tokens=2048,
urls=urls,
mode=mode,
)
urls = self.url_manager.pick_relevant_urls(section, research)
prompt = self._build_prompt(section, research, prev_summary, urls)
# Provider-agnostic text generation (respect GPT_PROVIDER & circuit-breaker)
content_text: str = ""
try:
ai_resp = llm_text_gen(
prompt=prompt,
json_struct=None,
system_prompt=None,
)
if isinstance(ai_resp, dict) and ai_resp.get("text"):
content_text = ai_resp.get("text", "")
elif isinstance(ai_resp, str):
content_text = ai_resp
else:
# Fallback best-effort extraction
content_text = str(ai_resp or "")
except Exception as e:
content_text = ""
result = {
"content": content_text,
"sources": [{"title": u.get("title", ""), "url": u.get("url", "")} for u in urls] if urls else [],
}
# Generate transition and compute intelligent flow metrics
previous_text = prev_summary
current_text = result.get("content", "")
@@ -56,19 +71,22 @@ class EnhancedContentGenerator:
pass
return result
def _build_prompt(self, section: Any, research: Any, prev_summary: str) -> str:
def _build_prompt(self, section: Any, research: Any, prev_summary: str, urls: list) -> str:
heading = getattr(section, 'heading', 'Section')
key_points = getattr(section, 'key_points', [])
keywords = getattr(section, 'keywords', [])
target_words = getattr(section, 'target_words', 300)
url_block = "\n".join([f"- {u.get('title','')} ({u.get('url','')})" for u in urls]) if urls else "(no specific URLs provided)"
return (
f"You are writing the blog section '{heading}'.\n\n"
f"Context summary: {prev_summary}\n"
f"Key points: {', '.join(key_points)}\n"
f"Keywords: {', '.join(keywords)}\n"
f"Target word count: {target_words}.\n"
"Use only factual info from provided sources; add short transition, then body."
f"Context summary (previous sections): {prev_summary}\n\n"
f"Authoring requirements:\n"
f"- Target word count: ~{target_words}\n"
f"- Use the following key points: {', '.join(key_points)}\n"
f"- Include these keywords naturally: {', '.join(keywords)}\n"
f"- Cite insights from these sources when relevant (do not output raw URLs):\n{url_block}\n\n"
"Write engaging, well-structured markdown with clear paragraphs (2-4 sentences each) separated by double line breaks."
)

View File

@@ -15,7 +15,7 @@ from models.blog_models import (
MediumGeneratedSection,
ResearchSource,
)
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
from services.cache.persistent_content_cache import persistent_content_cache
@@ -176,11 +176,9 @@ class MediumBlogGenerator:
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
)
ai_resp = gemini_structured_json_response(
ai_resp = llm_text_gen(
prompt=prompt,
schema=schema,
temperature=0.2,
max_tokens=8192,
json_struct=schema,
system_prompt=system,
)

View File

@@ -275,11 +275,17 @@ class BlogWriterService:
# Initialize metadata generator
metadata_generator = BlogSEOMetadataGenerator()
# Generate comprehensive metadata
# Extract outline and seo_analysis from request
outline = request.outline if hasattr(request, 'outline') else None
seo_analysis = request.seo_analysis if hasattr(request, 'seo_analysis') else None
# Generate comprehensive metadata with full context
metadata_results = await metadata_generator.generate_comprehensive_metadata(
blog_content=request.content,
blog_title=request.title or "Untitled Blog Post",
research_data=request.research_data or {}
research_data=request.research_data or {},
outline=outline,
seo_analysis=seo_analysis
)
# Convert to BlogSEOMetadataResponse format

View File

@@ -40,7 +40,7 @@ Return JSON format:
}}"""
try:
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
optimization_schema = {
"type": "object",
@@ -64,11 +64,10 @@ Return JSON format:
"propertyOrdering": ["outline"]
}
optimized_data = gemini_structured_json_response(
optimized_data = llm_text_gen(
prompt=optimization_prompt,
schema=optimization_schema,
temperature=0.3,
max_tokens=6000 # Match main outline generator
json_struct=optimization_schema,
system_prompt=None
)
# Handle the new schema format with "outline" wrapper

View File

@@ -20,7 +20,7 @@ class ResponseProcessor:
async def generate_with_retry(self, prompt: str, schema: Dict[str, Any], task_id: str = None) -> Dict[str, Any]:
"""Generate outline with retry logic for API failures."""
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
from api.blog_writer.task_manager import task_manager
max_retries = 2 # Conservative retry for expensive API calls
@@ -29,17 +29,16 @@ class ResponseProcessor:
for attempt in range(max_retries + 1):
try:
if task_id:
await task_manager.update_progress(task_id, f"🤖 Calling Gemini API for outline generation (attempt {attempt + 1}/{max_retries + 1})...")
await task_manager.update_progress(task_id, f"🤖 Calling AI API for outline generation (attempt {attempt + 1}/{max_retries + 1})...")
outline_data = gemini_structured_json_response(
outline_data = llm_text_gen(
prompt=prompt,
schema=schema,
temperature=0.3,
max_tokens=6000 # Increased further to avoid truncation
json_struct=schema,
system_prompt=None
)
# Log response for debugging
logger.info(f"Gemini response received: {type(outline_data)}")
logger.info(f"AI response received: {type(outline_data)}")
# Check for errors in the response
if isinstance(outline_data, dict) and 'error' in outline_data:
@@ -47,17 +46,17 @@ class ResponseProcessor:
if "503" in error_msg and "overloaded" in error_msg and attempt < max_retries:
if task_id:
await task_manager.update_progress(task_id, f"⚠️ AI service overloaded, retrying in {retry_delay} seconds...")
logger.warning(f"Gemini API overloaded, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
logger.warning(f"AI API overloaded, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
await asyncio.sleep(retry_delay)
continue
elif "No valid structured response content found" in error_msg and attempt < max_retries:
if task_id:
await task_manager.update_progress(task_id, f"⚠️ Invalid response format, retrying in {retry_delay} seconds...")
logger.warning(f"Gemini response parsing failed, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
logger.warning(f"AI response parsing failed, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
await asyncio.sleep(retry_delay)
continue
else:
logger.error(f"Gemini structured response error: {outline_data['error']}")
logger.error(f"AI structured response error: {outline_data['error']}")
raise ValueError(f"AI outline generation failed: {outline_data['error']}")
# Validate required fields
@@ -69,7 +68,7 @@ class ResponseProcessor:
await asyncio.sleep(retry_delay)
continue
else:
raise ValueError("Invalid outline structure in Gemini response")
raise ValueError("Invalid outline structure in AI response")
# If we get here, the response is valid
return outline_data
@@ -79,7 +78,7 @@ class ResponseProcessor:
if ("503" in error_str or "overloaded" in error_str) and attempt < max_retries:
if task_id:
await task_manager.update_progress(task_id, f"⚠️ AI service error, retrying in {retry_delay} seconds...")
logger.warning(f"Gemini API error, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1}): {error_str}")
logger.warning(f"AI API error, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1}): {error_str}")
await asyncio.sleep(retry_delay)
continue
else:

View File

@@ -44,7 +44,7 @@ class SectionEnhancer:
"""
try:
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
enhancement_schema = {
"type": "object",
@@ -58,11 +58,10 @@ class SectionEnhancer:
"required": ["heading", "subheadings", "key_points", "target_words", "keywords"]
}
enhanced_data = gemini_structured_json_response(
enhanced_data = llm_text_gen(
prompt=enhancement_prompt,
schema=enhancement_schema,
temperature=0.4,
max_tokens=1000
json_struct=enhancement_schema,
system_prompt=None
)
if isinstance(enhanced_data, dict) and 'error' not in enhanced_data:

View File

@@ -559,14 +559,11 @@ Analyze the mapping and provide your recommendations.
AI validation response
"""
try:
from services.llm_providers.gemini_provider import gemini_text_response
from services.llm_providers.main_text_generation import llm_text_gen
response = gemini_text_response(
response = llm_text_gen(
prompt=prompt,
temperature=0.3,
top_p=0.9,
n=1,
max_tokens=2000,
json_struct=None,
system_prompt=None
)

View File

@@ -10,13 +10,13 @@ import re
import textstat
from datetime import datetime
from typing import Dict, Any, List, Optional
from loguru import logger
from utils.logger_utils import get_service_logger
from services.seo_analyzer import (
ContentAnalyzer, KeywordAnalyzer,
URLStructureAnalyzer, AIInsightGenerator
)
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
class BlogContentSEOAnalyzer:
@@ -24,11 +24,13 @@ class BlogContentSEOAnalyzer:
def __init__(self):
"""Initialize the blog content SEO analyzer"""
# Service-specific logger (no global reconfiguration)
global logger
logger = get_service_logger("blog_content_seo_analyzer")
self.content_analyzer = ContentAnalyzer()
self.keyword_analyzer = KeywordAnalyzer()
self.url_analyzer = URLStructureAnalyzer()
self.ai_insights = AIInsightGenerator()
self.gemini_provider = gemini_structured_json_response
logger.info("BlogContentSEOAnalyzer initialized")
@@ -598,7 +600,7 @@ class BlogContentSEOAnalyzer:
return recommendations
async def _run_ai_analysis(self, blog_content: str, keywords_data: Dict[str, Any], non_ai_results: Dict[str, Any]) -> Dict[str, Any]:
"""Run single AI analysis for structured insights"""
"""Run single AI analysis for structured insights (provider-agnostic)"""
try:
# Prepare context for AI analysis
context = {
@@ -610,7 +612,6 @@ class BlogContentSEOAnalyzer:
# Create AI prompt for structured analysis
prompt = self._create_ai_analysis_prompt(context)
# Get structured response from Gemini
schema = {
"type": "object",
"properties": {
@@ -653,18 +654,17 @@ class BlogContentSEOAnalyzer:
}
}
ai_response = self.gemini_provider(
# Provider-agnostic structured response respecting GPT_PROVIDER
ai_response = llm_text_gen(
prompt=prompt,
schema=schema,
temperature=0.2,
max_tokens=8192
json_struct=schema,
system_prompt=None
)
return ai_response
except Exception as e:
logger.error(f"AI analysis failed: {e}")
# Fail fast - don't return mock data
raise e
def _create_ai_analysis_prompt(self, context: Dict[str, Any]) -> str:

View File

@@ -12,7 +12,7 @@ from datetime import datetime
from typing import Dict, Any, List, Optional
from loguru import logger
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
class BlogSEOMetadataGenerator:
@@ -20,14 +20,15 @@ class BlogSEOMetadataGenerator:
def __init__(self):
"""Initialize the metadata generator"""
self.gemini_provider = gemini_structured_json_response
logger.info("BlogSEOMetadataGenerator initialized")
async def generate_comprehensive_metadata(
self,
blog_content: str,
blog_title: str,
research_data: Dict[str, Any]
research_data: Dict[str, Any],
outline: Optional[List[Dict[str, Any]]] = None,
seo_analysis: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Generate comprehensive SEO metadata using maximum 2 AI calls
@@ -36,6 +37,8 @@ class BlogSEOMetadataGenerator:
blog_content: The blog content to analyze
blog_title: The blog title
research_data: Research data containing keywords and insights
outline: Outline structure with sections and headings
seo_analysis: SEO analysis results from previous phase
Returns:
Comprehensive metadata including all SEO elements
@@ -49,11 +52,15 @@ class BlogSEOMetadataGenerator:
# Call 1: Generate core SEO metadata (parallel with Call 2)
logger.info("Generating core SEO metadata")
core_metadata_task = self._generate_core_metadata(blog_content, blog_title, keywords_data)
core_metadata_task = self._generate_core_metadata(
blog_content, blog_title, keywords_data, outline, seo_analysis
)
# Call 2: Generate social media and structured data (parallel with Call 1)
logger.info("Generating social media and structured data")
social_metadata_task = self._generate_social_metadata(blog_content, blog_title, keywords_data)
social_metadata_task = self._generate_social_metadata(
blog_content, blog_title, keywords_data, outline, seo_analysis
)
# Wait for both calls to complete
core_metadata, social_metadata = await asyncio.gather(
@@ -105,12 +112,16 @@ class BlogSEOMetadataGenerator:
self,
blog_content: str,
blog_title: str,
keywords_data: Dict[str, Any]
keywords_data: Dict[str, Any],
outline: Optional[List[Dict[str, Any]]] = None,
seo_analysis: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Generate core SEO metadata (Call 1)"""
try:
# Create comprehensive prompt for core metadata
prompt = self._create_core_metadata_prompt(blog_content, blog_title, keywords_data)
prompt = self._create_core_metadata_prompt(
blog_content, blog_title, keywords_data, outline, seo_analysis
)
# Define simplified structured schema for core metadata
schema = {
@@ -155,17 +166,26 @@ class BlogSEOMetadataGenerator:
"required": ["seo_title", "meta_description", "url_slug", "blog_tags", "blog_categories", "social_hashtags", "reading_time", "focus_keyword"]
}
# Get structured response from Gemini
ai_response = self.gemini_provider(
prompt,
schema,
temperature=0.3,
max_tokens=2048
# Get structured response using provider-agnostic llm_text_gen
ai_response_raw = llm_text_gen(
prompt=prompt,
json_struct=schema,
system_prompt=None
)
# Handle response: llm_text_gen may return dict (from structured JSON) or str (needs parsing)
ai_response = ai_response_raw
if isinstance(ai_response_raw, str):
try:
import json
ai_response = json.loads(ai_response_raw)
except json.JSONDecodeError:
logger.error(f"Failed to parse JSON response: {ai_response_raw[:200]}...")
ai_response = None
# Check if we got a valid response
if not ai_response or not isinstance(ai_response, dict):
logger.error("Core metadata generation failed: Invalid response from Gemini")
logger.error("Core metadata generation failed: Invalid response from LLM")
# Return fallback response
primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
word_count = len(blog_content.split())
@@ -193,12 +213,16 @@ class BlogSEOMetadataGenerator:
self,
blog_content: str,
blog_title: str,
keywords_data: Dict[str, Any]
keywords_data: Dict[str, Any],
outline: Optional[List[Dict[str, Any]]] = None,
seo_analysis: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Generate social media and structured data (Call 2)"""
try:
# Create comprehensive prompt for social metadata
prompt = self._create_social_metadata_prompt(blog_content, blog_title, keywords_data)
prompt = self._create_social_metadata_prompt(
blog_content, blog_title, keywords_data, outline, seo_analysis
)
# Define simplified structured schema for social metadata
schema = {
@@ -246,17 +270,26 @@ class BlogSEOMetadataGenerator:
"required": ["open_graph", "twitter_card", "json_ld_schema"]
}
# Get structured response from Gemini
ai_response = self.gemini_provider(
prompt,
schema,
temperature=0.3,
max_tokens=2048
# Get structured response using provider-agnostic llm_text_gen
ai_response_raw = llm_text_gen(
prompt=prompt,
json_struct=schema,
system_prompt=None
)
# Handle response: llm_text_gen may return dict (from structured JSON) or str (needs parsing)
ai_response = ai_response_raw
if isinstance(ai_response_raw, str):
try:
import json
ai_response = json.loads(ai_response_raw)
except json.JSONDecodeError:
logger.error(f"Failed to parse JSON response: {ai_response_raw[:200]}...")
ai_response = None
# Check if we got a valid response
if not ai_response or not isinstance(ai_response, dict) or not ai_response.get('open_graph') or not ai_response.get('twitter_card') or not ai_response.get('json_ld_schema'):
logger.error("Social metadata generation failed: Invalid or empty response from Gemini")
logger.error("Social metadata generation failed: Invalid or empty response from LLM")
# Return fallback response
return {
'open_graph': {
@@ -301,11 +334,47 @@ class BlogSEOMetadataGenerator:
logger.error(f"Social metadata generation failed: {e}")
raise e
def _extract_content_highlights(self, blog_content: str, max_length: int = 2500) -> str:
"""Extract key sections from blog content for prompt context"""
try:
lines = blog_content.split('\n')
# Get first paragraph (introduction)
intro = ""
for line in lines[:20]:
if line.strip() and not line.strip().startswith('#'):
intro += line.strip() + " "
if len(intro) > 300:
break
# Get section headings
headings = [line.strip() for line in lines if line.strip().startswith('##')][:6]
# Get conclusion if available
conclusion = ""
for line in reversed(lines[-20:]):
if line.strip() and not line.strip().startswith('#'):
conclusion = line.strip() + " " + conclusion
if len(conclusion) > 300:
break
highlights = f"INTRODUCTION: {intro[:300]}...\n\n"
highlights += f"SECTION HEADINGS: {' | '.join([h.replace('##', '').strip() for h in headings])}\n\n"
if conclusion:
highlights += f"CONCLUSION: {conclusion[:300]}..."
return highlights[:max_length]
except Exception as e:
logger.warning(f"Failed to extract content highlights: {e}")
return blog_content[:2000] + "..."
def _create_core_metadata_prompt(
self,
blog_content: str,
blog_title: str,
keywords_data: Dict[str, Any]
keywords_data: Dict[str, Any],
outline: Optional[List[Dict[str, Any]]] = None,
seo_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""Create high-quality prompt for core metadata generation"""
@@ -314,30 +383,106 @@ class BlogSEOMetadataGenerator:
search_intent = keywords_data.get('search_intent', 'informational')
target_audience = keywords_data.get('target_audience', 'general')
industry = keywords_data.get('industry', 'general')
# Calculate word count for reading time estimation
word_count = len(blog_content.split())
# Extract outline structure
outline_context = ""
if outline:
headings = [s.get('heading', '') for s in outline if s.get('heading')]
outline_context = f"""
OUTLINE STRUCTURE:
- Total sections: {len(outline)}
- Section headings: {', '.join(headings[:8])}
- Content hierarchy: Well-structured with {len(outline)} main sections
"""
# Extract SEO analysis insights
seo_context = ""
if seo_analysis:
overall_score = seo_analysis.get('overall_score', seo_analysis.get('seo_score', 0))
category_scores = seo_analysis.get('category_scores', {})
applied_recs = seo_analysis.get('applied_recommendations', [])
seo_context = f"""
SEO ANALYSIS RESULTS:
- Overall SEO Score: {overall_score}/100
- Category Scores: Structure {category_scores.get('structure', category_scores.get('Structure', 0))}, Keywords {category_scores.get('keywords', category_scores.get('Keywords', 0))}, Readability {category_scores.get('readability', category_scores.get('Readability', 0))}
- Applied Recommendations: {len(applied_recs)} SEO optimizations have been applied
- Content Quality: Optimized for search engines with keyword focus
"""
# Get more content context (key sections instead of just first 1000 chars)
content_preview = self._extract_content_highlights(blog_content)
prompt = f"""
Generate SEO metadata for this blog post.
Generate comprehensive, personalized SEO metadata for this blog post.
BLOG TITLE: {blog_title}
BLOG CONTENT: {blog_content[:1000]}...
=== BLOG CONTENT CONTEXT ===
TITLE: {blog_title}
CONTENT PREVIEW (key sections): {content_preview}
WORD COUNT: {word_count} words
READING TIME ESTIMATE: {max(1, word_count // 200)} minutes
{outline_context}
=== KEYWORD & AUDIENCE DATA ===
PRIMARY KEYWORDS: {primary_keywords}
SEMANTIC KEYWORDS: {semantic_keywords}
WORD COUNT: {word_count}
SEARCH INTENT: {search_intent}
TARGET AUDIENCE: {target_audience}
INDUSTRY: {industry}
Generate:
1. SEO TITLE (50-60 characters) - include primary keyword
2. META DESCRIPTION (150-160 characters) - include CTA
3. URL SLUG (lowercase, hyphens, 3-5 words)
4. BLOG TAGS (5-8 relevant tags)
5. BLOG CATEGORIES (2-3 categories)
6. SOCIAL HASHTAGS (5-10 hashtags with #)
7. READING TIME (calculate from {word_count} words)
8. FOCUS KEYWORD (primary keyword for SEO)
{seo_context}
Make it compelling and SEO-optimized.
=== METADATA GENERATION REQUIREMENTS ===
1. SEO TITLE (50-60 characters, must include primary keyword):
- Front-load primary keyword
- Make it compelling and click-worthy
- Include power words if appropriate for {target_audience} audience
- Optimized for {search_intent} search intent
2. META DESCRIPTION (150-160 characters, must include CTA):
- Include primary keyword naturally in first 120 chars
- Add compelling call-to-action (e.g., "Learn more", "Discover how", "Get started")
- Highlight value proposition for {target_audience} audience
- Use {industry} industry-specific terminology where relevant
3. URL SLUG (lowercase, hyphens, 3-5 words):
- Include primary keyword
- Remove stop words
- Keep it concise and readable
4. BLOG TAGS (5-8 relevant tags):
- Mix of primary, semantic, and long-tail keywords
- Industry-specific tags for {industry}
- Audience-relevant tags for {target_audience}
5. BLOG CATEGORIES (2-3 categories):
- Based on content structure and {industry} industry standards
- Reflect main themes from outline sections
6. SOCIAL HASHTAGS (5-10 hashtags with #):
- Include primary keyword as hashtag
- Industry-specific hashtags for {industry}
- Trending/relevant hashtags for {target_audience}
7. READING TIME (calculate from {word_count} words):
- Average reading speed: 200 words/minute
- Round to nearest minute
8. FOCUS KEYWORD (primary keyword for SEO):
- Select the most important primary keyword
- Should match the main topic and search intent
=== QUALITY REQUIREMENTS ===
- All metadata must be unique, not generic
- Incorporate insights from SEO analysis if provided
- Reflect the actual content structure from outline
- Use language appropriate for {target_audience} audience
- Optimize for {search_intent} search intent
- Make descriptions compelling and action-oriented
Generate metadata that is personalized, compelling, and SEO-optimized.
"""
return prompt
@@ -345,7 +490,9 @@ Make it compelling and SEO-optimized.
self,
blog_content: str,
blog_title: str,
keywords_data: Dict[str, Any]
keywords_data: Dict[str, Any],
outline: Optional[List[Dict[str, Any]]] = None,
seo_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""Create high-quality prompt for social metadata generation"""
@@ -353,49 +500,68 @@ Make it compelling and SEO-optimized.
search_intent = keywords_data.get('search_intent', 'informational')
target_audience = keywords_data.get('target_audience', 'general')
industry = keywords_data.get('industry', 'general')
current_date = datetime.now().isoformat()
# Add outline and SEO context similar to core metadata prompt
outline_context = ""
if outline:
headings = [s.get('heading', '') for s in outline if s.get('heading')]
outline_context = f"\nOUTLINE SECTIONS: {', '.join(headings[:6])}\n"
seo_context = ""
if seo_analysis:
overall_score = seo_analysis.get('overall_score', seo_analysis.get('seo_score', 0))
seo_context = f"\nSEO SCORE: {overall_score}/100 (optimized content)\n"
content_preview = self._extract_content_highlights(blog_content, 1500)
prompt = f"""
Generate social media metadata for this blog post.
Generate engaging social media metadata for this blog post.
BLOG TITLE: {blog_title}
BLOG CONTENT: {blog_content[:800]}...
PRIMARY KEYWORDS: {primary_keywords}
=== CONTENT ===
TITLE: {blog_title}
CONTENT: {content_preview}
{outline_context}
{seo_context}
KEYWORDS: {primary_keywords}
TARGET AUDIENCE: {target_audience}
INDUSTRY: {industry}
CURRENT DATE: {current_date}
Generate:
=== GENERATION REQUIREMENTS ===
1. OPEN GRAPH (Facebook/LinkedIn):
- title: 60 chars max
- description: 160 chars max
- image: image URL
- title: 60 chars max, include primary keyword, compelling for {target_audience}
- description: 160 chars max, include CTA and value proposition
- image: Suggest an appropriate image URL (placeholder if none available)
- type: "article"
- site_name: site name
- url: canonical URL
- site_name: Use appropriate site name for {industry} industry
- url: Generate canonical URL structure
2. TWITTER CARD:
- card: "summary_large_image"
- title: 70 chars max
- description: 200 chars max with hashtags
- image: image URL
- site: @sitename
- creator: @author
- title: 70 chars max, optimized for Twitter audience
- description: 200 chars max with relevant hashtags inline
- image: Match Open Graph image
- site: @yourwebsite (placeholder, user should update)
- creator: @author (placeholder, user should update)
3. JSON-LD SCHEMA:
3. JSON-LD SCHEMA (Article):
- @context: "https://schema.org"
- @type: "Article"
- headline: article title
- description: article description
- author: {{"@type": "Person", "name": "Author Name"}}
- publisher: {{"@type": "Organization", "name": "Site Name"}}
- datePublished: ISO date
- dateModified: ISO date
- mainEntityOfPage: canonical URL
- keywords: array of keywords
- wordCount: word count
- headline: Article title (optimized)
- description: Article description (150-200 chars)
- author: {{"@type": "Person", "name": "Author Name"}} (placeholder)
- publisher: {{"@type": "Organization", "name": "Site Name", "logo": {{"@type": "ImageObject", "url": "logo-url"}}}}
- datePublished: {current_date}
- dateModified: {current_date}
- mainEntityOfPage: {{"@type": "WebPage", "@id": "canonical-url"}}
- keywords: Array of primary and semantic keywords
- wordCount: {len(blog_content.split())}
- articleSection: Primary category based on content
- inLanguage: "en-US"
Make it engaging and SEO-optimized.
Make it engaging, personalized for {target_audience}, and optimized for {industry} industry.
"""
return prompt

View File

@@ -0,0 +1,269 @@
"""Blog SEO Recommendation Applier
Applies actionable SEO recommendations to existing blog content using the
provider-agnostic `llm_text_gen` dispatcher. Ensures GPT_PROVIDER parity.
"""
import asyncio
from typing import Dict, Any, List
from utils.logger_utils import get_service_logger
from services.llm_providers.main_text_generation import llm_text_gen
logger = get_service_logger("blog_seo_recommendation_applier")
class BlogSEORecommendationApplier:
"""Apply actionable SEO recommendations to blog content."""
def __init__(self):
logger.debug("Initialized BlogSEORecommendationApplier")
async def apply_recommendations(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Apply recommendations and return updated content."""
title = payload.get("title", "Untitled Blog")
sections: List[Dict[str, Any]] = payload.get("sections", [])
outline = payload.get("outline", [])
research = payload.get("research", {})
recommendations = payload.get("recommendations", [])
persona = payload.get("persona", {})
tone = payload.get("tone")
audience = payload.get("audience")
if not sections:
return {"success": False, "error": "No sections provided for recommendation application"}
if not recommendations:
logger.warning("apply_recommendations called without recommendations")
return {"success": True, "title": title, "sections": sections, "applied": []}
prompt = self._build_prompt(
title=title,
sections=sections,
outline=outline,
research=research,
recommendations=recommendations,
persona=persona,
tone=tone,
audience=audience,
)
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"heading": {"type": "string"},
"content": {"type": "string"},
"notes": {"type": "array", "items": {"type": "string"}},
},
"required": ["id", "heading", "content"],
},
},
"applied_recommendations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category": {"type": "string"},
"summary": {"type": "string"},
},
},
},
},
"required": ["sections"],
}
logger.info("Applying SEO recommendations via llm_text_gen")
result = await asyncio.to_thread(
llm_text_gen,
prompt,
None,
schema,
)
if not result or result.get("error"):
error_msg = result.get("error", "Unknown error") if result else "No response from text generator"
logger.error(f"SEO recommendation application failed: {error_msg}")
return {"success": False, "error": error_msg}
raw_sections = result.get("sections", []) or []
normalized_sections: List[Dict[str, Any]] = []
# Build lookup table from updated sections using their identifiers
updated_map: Dict[str, Dict[str, Any]] = {}
for updated in raw_sections:
section_id = str(
updated.get("id")
or updated.get("section_id")
or updated.get("heading")
or ""
).strip()
if not section_id:
continue
heading = (
updated.get("heading")
or updated.get("title")
or section_id
)
content_text = updated.get("content", "")
if isinstance(content_text, list):
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
updated_map[section_id] = {
"id": section_id,
"heading": heading,
"content": str(content_text).strip(),
"notes": updated.get("notes", []),
}
if not updated_map and raw_sections:
logger.warning("Updated sections missing identifiers; falling back to positional mapping")
for index, original in enumerate(sections):
fallback_id = str(
original.get("id")
or original.get("section_id")
or f"section_{index + 1}"
).strip()
mapped = updated_map.get(fallback_id)
if not mapped and raw_sections:
# Fall back to positional match if identifier lookup failed
candidate = raw_sections[index] if index < len(raw_sections) else {}
heading = (
candidate.get("heading")
or candidate.get("title")
or original.get("heading")
or original.get("title")
or f"Section {index + 1}"
)
content_text = candidate.get("content") or original.get("content", "")
if isinstance(content_text, list):
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
mapped = {
"id": fallback_id,
"heading": heading,
"content": str(content_text).strip(),
"notes": candidate.get("notes", []),
}
if not mapped:
# Fallback to original content if nothing else available
mapped = {
"id": fallback_id,
"heading": original.get("heading") or original.get("title") or f"Section {index + 1}",
"content": str(original.get("content", "")).strip(),
"notes": original.get("notes", []),
}
normalized_sections.append(mapped)
applied = result.get("applied_recommendations", [])
logger.info("SEO recommendations applied successfully")
return {
"success": True,
"title": result.get("title", title),
"sections": normalized_sections,
"applied": applied,
}
def _build_prompt(
self,
*,
title: str,
sections: List[Dict[str, Any]],
outline: List[Dict[str, Any]],
research: Dict[str, Any],
recommendations: List[Dict[str, Any]],
persona: Dict[str, Any],
tone: str | None,
audience: str | None,
) -> str:
"""Construct prompt for applying recommendations."""
sections_str = []
for section in sections:
sections_str.append(
f"ID: {section.get('id', 'section')}, Heading: {section.get('heading', 'Untitled')}\n"
f"Current Content:\n{section.get('content', '')}\n"
)
outline_str = "\n".join(
[
f"- {item.get('heading', 'Section')} (Target words: {item.get('target_words', 'N/A')})"
for item in outline
]
)
research_summary = research.get("keyword_analysis", {}) if research else {}
primary_keywords = ", ".join(research_summary.get("primary", [])[:10]) or "None"
recommendations_str = []
for rec in recommendations:
recommendations_str.append(
f"Category: {rec.get('category', 'General')} | Priority: {rec.get('priority', 'Medium')}\n"
f"Recommendation: {rec.get('recommendation', '')}\n"
f"Impact: {rec.get('impact', '')}\n"
)
persona_str = (
f"Persona: {persona}\n"
if persona
else "Persona: (not provided)\n"
)
style_guidance = []
if tone:
style_guidance.append(f"Desired tone: {tone}")
if audience:
style_guidance.append(f"Target audience: {audience}")
style_str = "\n".join(style_guidance) if style_guidance else "Maintain current tone and audience alignment."
prompt = f"""
You are an expert SEO content strategist. Update the blog content to apply the actionable recommendations.
Current Title: {title}
Primary Keywords (for context): {primary_keywords}
Outline Overview:
{outline_str or 'No outline supplied'}
Existing Sections:
{''.join(sections_str)}
Actionable Recommendations to Apply:
{''.join(recommendations_str)}
{persona_str}
{style_str}
Instructions:
1. Carefully apply the recommendations while preserving factual accuracy and research alignment.
2. Keep section identifiers (IDs) unchanged so the frontend can map updates correctly.
3. Improve clarity, flow, and SEO optimization per the guidance.
4. Return updated sections in the requested JSON format.
5. Provide a short summary of which recommendations were addressed.
"""
return prompt
__all__ = ["BlogSEORecommendationApplier"]

View File

@@ -16,7 +16,7 @@ from io import BytesIO
# Import existing infrastructure
from ...onboarding.api_key_manager import APIKeyManager
from ...llm_providers.text_to_image_generation.gen_gemini_images import generate_gemini_image
from ...llm_providers.main_image_generation import generate_image
# Set up logging
logger = logging.getLogger(__name__)
@@ -270,41 +270,57 @@ class LinkedInImageGenerator:
async def _generate_with_gemini(self, prompt: str, aspect_ratio: str) -> Dict[str, Any]:
"""
Generate image using existing Gemini infrastructure.
Generate image using unified image generation infrastructure.
Args:
prompt: Enhanced prompt for image generation
aspect_ratio: Desired aspect ratio
Returns:
Generation result from Gemini
Generation result from image generation provider
"""
try:
# Use existing Gemini image generation function
# This integrates with the current infrastructure
result = generate_gemini_image(prompt, aspect_ratio=aspect_ratio)
# Map aspect ratio to dimensions (LinkedIn-optimized)
aspect_map = {
"1:1": (1024, 1024),
"16:9": (1920, 1080),
"4:3": (1366, 1024),
"9:16": (1080, 1920), # Portrait for stories
}
width, height = aspect_map.get(aspect_ratio, (1024, 1024))
if result and os.path.exists(result):
# Read the generated image
with open(result, 'rb') as f:
image_data = f.read()
# Use unified image generation system (defaults to provider based on GPT_PROVIDER)
result = generate_image(
prompt=prompt,
options={
"provider": "gemini", # LinkedIn uses Gemini by default
"model": self.model if hasattr(self, 'model') else None,
"width": width,
"height": height,
}
)
if result and result.image_bytes:
return {
'success': True,
'image_data': image_data,
'image_path': result
'image_data': result.image_bytes,
'image_path': None, # No file path, using bytes directly
'width': result.width,
'height': result.height,
'provider': result.provider,
'model': result.model,
}
else:
return {
'success': False,
'error': 'Gemini image generation returned no result'
'error': 'Image generation returned no result'
}
except Exception as e:
logger.error(f"Error in Gemini image generation: {str(e)}")
logger.error(f"Error in image generation: {str(e)}")
return {
'success': False,
'error': f"Gemini generation failed: {str(e)}"
'error': f"Image generation failed: {str(e)}"
}
async def _process_generated_image(

View File

@@ -0,0 +1,15 @@
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
from .hf_provider import HuggingFaceImageProvider
from .gemini_provider import GeminiImageProvider
from .stability_provider import StabilityImageProvider
__all__ = [
"ImageGenerationOptions",
"ImageGenerationResult",
"ImageGenerationProvider",
"HuggingFaceImageProvider",
"GeminiImageProvider",
"StabilityImageProvider",
]

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Dict, Any, Protocol
@dataclass
class ImageGenerationOptions:
prompt: str
negative_prompt: Optional[str] = None
width: int = 1024
height: int = 1024
guidance_scale: Optional[float] = None
steps: Optional[int] = None
seed: Optional[int] = None
model: Optional[str] = None
extra: Optional[Dict[str, Any]] = None
@dataclass
class ImageGenerationResult:
image_bytes: bytes
width: int
height: int
provider: str
model: Optional[str] = None
seed: Optional[int] = None
metadata: Optional[Dict[str, Any]] = None
class ImageGenerationProvider(Protocol):
"""Protocol for image generation providers."""
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
...

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import io
import os
from typing import Optional
from PIL import Image
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
from utils.logger_utils import get_service_logger
logger = get_service_logger("image_generation.gemini")
class GeminiImageProvider(ImageGenerationProvider):
"""Google Gemini/Imagen backed image generation.
NOTE: Implementation should call the actual Gemini Images API used in the codebase.
Here we keep a minimal interface and expect the underlying client to be wired
similarly to other providers and return a PIL image or raw bytes.
"""
def __init__(self) -> None:
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
logger.warning("GOOGLE_API_KEY not set. Gemini image generation may fail at runtime.")
logger.info("GeminiImageProvider initialized")
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
# Placeholder implementation to be replaced by real Gemini/Imagen call.
# For now, generate a 1x1 transparent PNG to maintain interface consistency
img = Image.new("RGBA", (max(1, options.width), max(1, options.height)), (0, 0, 0, 0))
with io.BytesIO() as buf:
img.save(buf, format="PNG")
png = buf.getvalue()
return ImageGenerationResult(
image_bytes=png,
width=img.width,
height=img.height,
provider="gemini",
model=os.getenv("GEMINI_IMAGE_MODEL"),
seed=options.seed,
)

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import io
import os
from typing import Optional, Dict, Any
from PIL import Image
from huggingface_hub import InferenceClient
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
from utils.logger_utils import get_service_logger
logger = get_service_logger("image_generation.huggingface")
DEFAULT_HF_MODEL = os.getenv(
"HF_IMAGE_MODEL",
"black-forest-labs/FLUX.1-Krea-dev",
)
class HuggingFaceImageProvider(ImageGenerationProvider):
"""Hugging Face Inference Providers (fal-ai) backed image generation.
API doc: https://huggingface.co/docs/inference-providers/en/tasks/text-to-image
"""
def __init__(self, api_key: Optional[str] = None, provider: str = "fal-ai") -> None:
self.api_key = api_key or os.getenv("HF_TOKEN")
if not self.api_key:
raise RuntimeError("HF_TOKEN is required for Hugging Face image generation")
self.provider = provider
self.client = InferenceClient(provider=self.provider, api_key=self.api_key)
logger.info("HuggingFaceImageProvider initialized (provider=%s)", self.provider)
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
model = options.model or DEFAULT_HF_MODEL
params: Dict[str, Any] = {}
if options.guidance_scale is not None:
params["guidance_scale"] = options.guidance_scale
if options.steps is not None:
params["num_inference_steps"] = options.steps
if options.negative_prompt:
params["negative_prompt"] = options.negative_prompt
if options.seed is not None:
params["seed"] = options.seed
# The HF InferenceClient returns a PIL Image
logger.debug("HF generate: model=%s width=%s height=%s params=%s", model, options.width, options.height, params)
img: Image.Image = self.client.text_to_image(
options.prompt,
model=model,
width=options.width,
height=options.height,
**params,
)
with io.BytesIO() as buf:
img.save(buf, format="PNG")
image_bytes = buf.getvalue()
return ImageGenerationResult(
image_bytes=image_bytes,
width=img.width,
height=img.height,
provider="huggingface",
model=model,
seed=options.seed,
metadata={"provider": self.provider},
)

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import io
import os
from typing import Optional, Dict, Any
import requests
from PIL import Image
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
from utils.logger_utils import get_service_logger
logger = get_service_logger("image_generation.stability")
DEFAULT_STABILITY_MODEL = os.getenv("STABILITY_MODEL", "stable-diffusion-xl-1024-v1-0")
class StabilityImageProvider(ImageGenerationProvider):
"""Stability AI Images API provider (simple text-to-image).
This uses the v1 text-to-image endpoint format. Adjust to match your existing
Stability integration if different.
"""
def __init__(self, api_key: Optional[str] = None) -> None:
self.api_key = api_key or os.getenv("STABILITY_API_KEY")
if not self.api_key:
logger.warning("STABILITY_API_KEY not set. Stability generation may fail at runtime.")
logger.info("StabilityImageProvider initialized")
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
payload: Dict[str, Any] = {
"text_prompts": [
{"text": options.prompt, "weight": 1.0},
],
"cfg_scale": options.guidance_scale or 7.0,
"steps": options.steps or 30,
"width": options.width,
"height": options.height,
"seed": options.seed,
}
if options.negative_prompt:
payload["text_prompts"].append({"text": options.negative_prompt, "weight": -1.0})
model = options.model or DEFAULT_STABILITY_MODEL
url = f"https://api.stability.ai/v1/generation/{model}/text-to-image"
logger.debug("Stability generate: model=%s payload_keys=%s", model, list(payload.keys()))
resp = requests.post(url, headers=headers, json=payload, timeout=60)
resp.raise_for_status()
data = resp.json()
# Expecting data["artifacts"][0]["base64"]
import base64
artifact = (data.get("artifacts") or [{}])[0]
b64 = artifact.get("base64", "")
image_bytes = base64.b64decode(b64)
# Confirm dimensions by loading once (optional)
img = Image.open(io.BytesIO(image_bytes))
return ImageGenerationResult(
image_bytes=image_bytes,
width=img.width,
height=img.height,
provider="stability",
model=model,
seed=options.seed,
)

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import os
from typing import Optional, Dict, Any
from .image_generation import (
ImageGenerationOptions,
ImageGenerationResult,
HuggingFaceImageProvider,
GeminiImageProvider,
StabilityImageProvider,
)
from utils.logger_utils import get_service_logger
logger = get_service_logger("image_generation.facade")
def _select_provider(explicit: Optional[str]) -> str:
if explicit:
return explicit
gpt_provider = (os.getenv("GPT_PROVIDER") or "").lower()
if gpt_provider.startswith("gemini"):
return "gemini"
if gpt_provider.startswith("hf"):
return "huggingface"
if os.getenv("STABILITY_API_KEY"):
return "stability"
# Fallback to huggingface to enable a path if configured
return "huggingface"
def _get_provider(provider_name: str):
if provider_name == "huggingface":
return HuggingFaceImageProvider()
if provider_name == "gemini":
return GeminiImageProvider()
if provider_name == "stability":
return StabilityImageProvider()
raise ValueError(f"Unknown image provider: {provider_name}")
def generate_image(prompt: str, options: Optional[Dict[str, Any]] = None) -> ImageGenerationResult:
opts = options or {}
provider_name = _select_provider(opts.get("provider"))
image_options = ImageGenerationOptions(
prompt=prompt,
negative_prompt=opts.get("negative_prompt"),
width=int(opts.get("width", 1024)),
height=int(opts.get("height", 1024)),
guidance_scale=opts.get("guidance_scale"),
steps=opts.get("steps"),
seed=opts.get("seed"),
model=opts.get("model"),
extra=opts,
)
# Normalize obvious model/provider mismatches
model_lower = (image_options.model or "").lower()
if provider_name == "stability" and (model_lower.startswith("black-forest-labs/") or model_lower.startswith("runwayml/") or model_lower.startswith("stabilityai/flux")):
logger.info("Remapping provider to huggingface for model=%s", image_options.model)
provider_name = "huggingface"
if provider_name == "huggingface" and not image_options.model:
# Provide a sensible default HF model if none specified
image_options.model = "black-forest-labs/FLUX.1-Krea-dev"
logger.info("Generating image via provider=%s model=%s", provider_name, image_options.model)
provider = _get_provider(provider_name)
return provider.generate(image_options)

View File

@@ -1,56 +0,0 @@
from openai import OpenAI
from loguru import logger
import sys
from .save_image import save_generated_image
from tenacity import (
retry,
stop_after_attempt,
wait_random_exponential,
) # for exponential backoff
@retry(wait=wait_random_exponential(min=1, max=120), stop=stop_after_attempt(6))
def generate_dalle3_images(img_prompt, image_dir, size="1024x1024", quality="hd", n=1):
"""
Generates images using the DALL-E 3 model based on a given text prompt.
Args:
img_prompt (str): Text prompt to generate the image.
image_dir (str): Directory where the generated image will be saved.
size (str, optional): Size of the generated images. Defaults to "1024x1024".
quality (str, optional): Quality of the generated images. Defaults to "hd".
n (int, optional): Number of images to generate. Defaults to 1.
Returns:
str: Path to the saved image.
Raises:
SystemExit: If an error occurs in image generation or saving.
"""
try:
logger.info("Generating Dall-e-3 image for the blog.")
client = OpenAI()
img_generation_response = client.images.generate(
model="dall-e-3",
prompt=img_prompt,
size=size,
quality=quality,
n=n
)
# Save the generated image locally.
try:
img_path = save_generated_image(img_generation_response, image_dir)
return img_path
except Exception as err:
logger.error(f"Failed to Save generated image: {err}")
except openai.OpenAIError as e:
logger.error(f"Dalle-3 image generation error: HTTP Status {e.http_status}, Error: {e.error}")
sys.exit("Exiting due to Dalle-3 image generation error.")
except Exception as e:
logger.error(f"Failed to generate images with Dalle3: {e}")
sys.exit("Exiting due to a general error in image generation.")

View File

@@ -1,53 +0,0 @@
from openai import OpenAI
from loguru import logger
import sys
from tenacity import (
retry,
stop_after_attempt,
wait_random_exponential,
) # for exponential backoff
from .save_image import save_generated_image
@retry(wait=wait_random_exponential(min=1, max=120), stop=stop_after_attempt(6))
def generate_dalle3_images(img_prompt, image_dir, size="1024x1024", quality="hd", n=1):
"""
Generates images using the DALL-E 3 model based on a given text prompt.
Args:
img_prompt (str): Text prompt to generate the image.
image_dir (str): Directory where the generated image will be saved.
size (str, optional): Size of the generated images. Defaults to "1024x1024".
quality (str, optional): Quality of the generated images. Defaults to "hd".
n (int, optional): Number of images to generate. Defaults to 1.
Returns:
str: Path to the saved image.
Raises:
SystemExit: If an error occurs in image generation or saving.
"""
try:
logger.info("Generating Dall-e-3 image for the blog.")
client = OpenAI()
img_generation_response = client.images.generate(
model="dall-e-3",
prompt=img_prompt,
size=size,
quality=quality,
n=n
)
img_path = save_generated_image(img_generation_response, image_dir)
return img_path
except openai.OpenAIError as e:
logger.error(f"Dalle-3 image generation error: HTTP Status {e.http_status}, Error: {e.error}")
sys.exit("Exiting due to Dalle-3 image generation error.")
except Exception as e:
logger.error(f"Failed to generate images with Dalle3: {e}")
sys.exit("Exiting due to a general error in image generation.")

View File

@@ -1,583 +0,0 @@
import os
import sys
import time
import datetime
import base64
import random
from typing import List, Optional, Tuple
from PIL import Image
from io import BytesIO
import logging
# Import APIKeyManager
from ...onboarding.api_key_manager import APIKeyManager
try:
from google import genai
from google.genai import types
except ImportError:
genai = None
logging.getLogger('gemini_image_generator').warning(
"Google genai library not available. Install with: pip install google-generativeai"
)
from .save_image import save_generated_image
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('gemini_image_generator')
# Imagen fallback configuration
IMAGEN_FALLBACK_CONFIG = {
'enabled': os.getenv('IMAGEN_FALLBACK_ENABLED', 'true').lower() == 'true', # Master switch for Imagen fallback
'auto_fallback': os.getenv('IMAGEN_AUTO_FALLBACK', 'true').lower() == 'true', # Automatically fall back on Gemini failures
'preferred_model': os.getenv('IMAGEN_MODEL', 'imagen-4.0-generate-001'), # Fast model for quick generation
'fallback_aspect_ratios': {
'1:1': '1:1',
'3:4': '3:4',
'4:3': '4:3',
'9:16': '9:16',
'16:9': '16:9'
},
'max_images': int(os.getenv('IMAGEN_MAX_IMAGES', '1')), # Generate 1 image for LinkedIn posts
}
# Log configuration on startup
logger.info(f"🔄 Imagen fallback configuration: {IMAGEN_FALLBACK_CONFIG}")
# With image generation in Gemini, your imagination is the limit.
# Follow Google AI best practices for detailed prompts and iterative refinement.
# Generate images using Gemini
# Gemini 2.0 Flash Experimental supports the ability to output text and inline images.
# This lets you use Gemini to conversationally edit images or generate outputs with interwoven text (for example, generating a blog post with text and images in a single turn).
# Note: Make sure to include responseModalities: ["Text", "Image"] in your generation configuration for text and image output with gemini-2.0-flash-exp-image-generation. Image only is not allowed.
class AIPromptGenerator:
"""
Generates enhanced AI image prompts based on user keywords,
following the guidelines of the Imagen documentation.
"""
def __init__(self):
self.photography_styles = ["photo", "photograph"]
self.art_styles = ["painting", "sketch", "drawing", "illustration", "digital art", "render"]
self.art_techniques = ["technical pencil drawing", "charcoal drawing", "color pencil drawing", "pastel painting", "digital art", "art deco (poster)", "impressionist painting", "renaissance painting", "pop art"]
self.camera_proximity = ["close-up", "zoomed out", "taken from far away"]
self.camera_position = ["aerial", "from below"]
self.lighting = ["natural lighting", "dramatic lighting", "warm lighting", "cold lighting", "studio lighting", "golden hour lighting"]
self.camera_settings = ["motion blur", "soft focus", "bokeh", "portrait"]
self.lens_types = ["35mm lens", "50mm lens", "fisheye lens", "wide angle lens", "macro lens", "telephoto lens"]
self.film_types = ["black and white film", "polaroid"]
self.materials = ["made of cheese", "made of paper", "made of neon tubes", "metallic", "glass", "wooden", "stone"]
self.shapes = ["in the shape of a bird", "angular", "curved", "geometric"]
self.quality_modifiers_general = ["high-quality", "beautiful", "stylized", "detailed", "epic", "grand"]
self.quality_modifiers_photo = ["4K", "HDR", "studio photo", "professional photo", "photorealistic"]
self.quality_modifiers_art = ["by a professional artist", "intricate details", "masterpiece"]
self.aspect_ratios = ["1:1 aspect ratio", "4:3 aspect ratio", "3:4 aspect ratio", "16:9 aspect ratio", "9:16 aspect ratio"]
self.photorealistic_modifiers = {
"portraits": ["prime lens", "zoom lens", "24-35mm", "black and white film", "film noir", "shallow depth of field", "duotone (mention two colors)"],
"objects": ["macro lens", "60-105mm", "high detail", "precise focusing", "controlled lighting"],
"motion": ["telephoto zoom lens", "100-400mm", "fast shutter speed", "action shot", "movement tracking"],
"wide-angle": ["wide-angle lens", "10-24mm", "long exposure", "sharp focus", "smooth water or clouds", "astro photography"]
}
def generate_prompt(self, keywords):
"""
Generates an enhanced AI image prompt based on user-provided keywords.
Args:
keywords (list): A list of keywords describing the desired image.
Returns:
str: An enhanced AI image prompt.
"""
if not keywords:
return "A beautiful image."
prompt_parts = []
subject = " ".join(keywords)
prompt_parts.append(subject)
# Add context and background (optional)
context_options = ["in a detailed background", "outdoors", "indoors", "in a studio", "with a blurred background"]
if random.random() < 0.6: # Add context with a probability
prompt_parts.append(random.choice(context_options))
# Add style (optional)
style_options = self.photography_styles + [f"{art} of" for art in self.art_styles]
if random.random() < 0.7:
prompt_parts.insert(0, random.choice(style_options))
if prompt_parts[0].startswith("painting of") or prompt_parts[0].startswith("sketch of") or prompt_parts[0].startswith("drawing of"):
if random.random() < 0.5:
prompt_parts.append(f"in the style of {random.choice(self.art_techniques)}")
# Add photography modifiers (if photography style is chosen)
if any(style in prompt_parts[0] for style in self.photography_styles):
if random.random() < 0.4:
prompt_parts.append(random.choice(self.camera_proximity))
if random.random() < 0.3:
prompt_parts.append(random.choice(self.camera_position))
if random.random() < 0.5:
prompt_parts.append(random.choice(self.lighting))
if random.random() < 0.3:
prompt_parts.append(random.choice(self.camera_settings))
if random.random() < 0.2:
prompt_parts.append(random.choice(self.lens_types))
if random.random() < 0.1:
prompt_parts.append(random.choice(self.film_types))
# Add shapes and materials (optional)
if random.random() < 0.3:
prompt_parts.append(random.choice(self.materials))
if random.random() < 0.2:
prompt_parts.append(random.choice(self.shapes))
# Add quality modifiers (optional)
if random.random() < 0.6:
quality_options = self.quality_modifiers_general
if any(style in prompt_parts[0] for style in self.photography_styles):
quality_options += self.quality_modifiers_photo
else:
quality_options += self.quality_modifiers_art
prompt_parts.append(random.choice(list(set(quality_options)))) # Avoid duplicates
# Add aspect ratio (optional)
if random.random() < 0.2:
prompt_parts.append(random.choice(self.aspect_ratios))
return ", ".join(prompt_parts)
def generate_photorealistic_prompt(self, keywords, focus=""):
"""
Generates an enhanced AI image prompt specifically for photorealistic images.
Args:
keywords (list): A list of keywords describing the desired image.
focus (str, optional): The focus of the photorealistic image (e.g., "portraits", "objects", "motion", "wide-angle"). Defaults to "".
Returns:
str: An enhanced photorealistic AI image prompt.
"""
if not keywords:
return "A photorealistic image."
prompt_parts = ["A photo of", "photorealistic"]
prompt_parts.append(" ".join(keywords))
if focus and focus in self.photorealistic_modifiers:
modifiers = self.photorealistic_modifiers[focus]
if modifiers:
num_modifiers = random.randint(1, min(3, len(modifiers)))
selected_modifiers = random.sample(modifiers, num_modifiers)
prompt_parts.extend(selected_modifiers)
# Add general quality modifiers
if random.random() < 0.5:
prompt_parts.append(random.choice(self.quality_modifiers_photo))
# Add lighting
if random.random() < 0.4:
prompt_parts.append(random.choice(self.lighting))
return ", ".join(prompt_parts)
def _ensure_client() -> Optional[object]:
"""Create a Gemini client if available and API key is configured."""
api_key_manager = APIKeyManager()
api_key = api_key_manager.get_api_key("gemini")
if not api_key or genai is None:
if not api_key:
logger.warning("No Gemini API key found")
if genai is None:
logger.warning("Google Generative AI library not available")
return None
try:
logger.info("Creating Gemini client...")
# Create a client using the correct API pattern
# The API key is passed directly to the Client constructor
client = genai.Client(api_key=api_key)
logger.info("Gemini client created successfully")
return client
except Exception as e:
logger.error(f"Failed to create Gemini client: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return None
def _generate_imagen_images_base64(prompt: str, aspect_ratio: str = "1:1") -> List[str]:
"""
Generate images using Imagen API as a fallback method.
This function implements the Imagen API following the official documentation:
https://ai.google.dev/gemini-api/docs/imagen
Args:
prompt: Text prompt for image generation
aspect_ratio: Desired aspect ratio (1:1, 3:4, 4:3, 9:16, 16:9)
Returns:
List of base64-encoded PNG images
"""
logger = logging.getLogger('gemini_image_generator')
logger.info("🔄 Falling back to Imagen API for image generation")
try:
# Get API key for Imagen (can use same Gemini API key)
api_key_manager = APIKeyManager()
api_key = api_key_manager.get_api_key("gemini") # Imagen uses same API key
if not api_key:
logger.error("No API key available for Imagen fallback")
return []
# Create Imagen client
client = genai.Client(api_key=api_key)
# Map aspect ratio to Imagen format using configuration
imagen_aspect_ratio = IMAGEN_FALLBACK_CONFIG['fallback_aspect_ratios'].get(aspect_ratio, "1:1")
# Optimize prompt for Imagen (remove Gemini-specific formatting)
imagen_prompt = _optimize_prompt_for_imagen(prompt)
logger.info(f"Generating Imagen images with prompt: {imagen_prompt[:100]}...")
logger.info(f"Using aspect ratio: {imagen_aspect_ratio}")
logger.info(f"Using model: {IMAGEN_FALLBACK_CONFIG['preferred_model']}")
# Generate images using configured Imagen model
# Note: sample_image_size is not supported in current library version
config_params = {
'number_of_images': IMAGEN_FALLBACK_CONFIG['max_images'],
'aspect_ratio': imagen_aspect_ratio,
}
# Add additional configuration options if needed
# config_params['guidance_scale'] = 7.5 # Optional: control image generation quality
# config_params['person_generation'] = 'allow_adult' # Optional: control person generation
response = client.models.generate_images(
model=IMAGEN_FALLBACK_CONFIG['preferred_model'],
prompt=imagen_prompt,
config=types.GenerateImagesConfig(**config_params)
)
# Extract base64 images from response
images_b64: List[str] = []
for generated_image in response.generated_images:
if hasattr(generated_image, 'image') and hasattr(generated_image.image, 'image_bytes'):
# Convert image bytes to base64
image_bytes = generated_image.image.image_bytes
if isinstance(image_bytes, bytes):
images_b64.append(base64.b64encode(image_bytes).decode('utf-8'))
else:
# If already base64 string
images_b64.append(str(image_bytes))
if images_b64:
logger.info(f"✅ Imagen fallback successful! Generated {len(images_b64)} images")
return images_b64
else:
logger.warning("Imagen fallback returned no images")
return []
except Exception as e:
logger.error(f"❌ Imagen fallback failed: {e}")
import traceback
logger.error(f"Imagen error traceback: {traceback.format_exc()}")
return []
def _optimize_prompt_for_imagen(prompt: str) -> str:
"""
Optimize prompt for Imagen API by removing Gemini-specific formatting
and enhancing it with Imagen best practices.
Based on Imagen prompt guide: https://ai.google.dev/gemini-api/docs/imagen
"""
# Remove Gemini-specific formatting
prompt = prompt.replace('\n\nEnhanced prompt:', '')
prompt = prompt.replace('\n\nAspect ratio:', '')
# Clean up extra whitespace
prompt = ' '.join(prompt.split())
# Add Imagen-specific enhancements if not present
if 'professional' in prompt.lower() and 'linkedin' in prompt.lower():
# Enhance for LinkedIn professional content
prompt += ", high quality, professional photography, business appropriate"
if 'digital transformation' in prompt.lower() or 'technology' in prompt.lower():
# Enhance for tech content
prompt += ", modern, innovative, clean design, corporate aesthetic"
# Ensure prompt doesn't exceed Imagen's 480 token limit
if len(prompt) > 400: # Leave some buffer
prompt = prompt[:400] + "..."
return prompt
def generate_gemini_images_base64(
prompt: str,
*,
keywords: Optional[list] = None,
style: Optional[str] = None,
focus: Optional[str] = None,
enhance_prompt: bool = True,
aspect_ratio: str = "9:16",
max_retries: int = 2,
initial_retry_delay: float = 1.0,
enable_imagen_fallback: bool = True,
) -> List[str]:
"""
Return list of base64 PNG images generated from a prompt.
Primary method: Gemini API for image generation
Fallback method: Imagen API when Gemini fails (quota limits, API errors, etc.)
Implements best practices per Gemini docs: send text prompt, parse inline image parts,
and return base64 data suitable for API responses. No Streamlit, no printing.
Docs:
- Gemini: https://ai.google.dev/gemini-api/docs/image-generation
- Imagen: https://ai.google.dev/gemini-api/docs/imagen
"""
logger = logging.getLogger('gemini_image_generator')
logger.info("Generating image (base64) with Gemini (with Imagen fallback)")
if enhance_prompt and keywords:
pg = AIPromptGenerator()
enhanced = (
pg.generate_photorealistic_prompt(keywords, focus)
if style == "photorealistic" and focus
else pg.generate_prompt(keywords)
)
prompt = f"{prompt}\n\nEnhanced prompt: {enhanced}"
# Optional hint in-text for aspect ratio; API doesn't take ratio param directly
if aspect_ratio:
prompt = f"{prompt}\n\nAspect ratio: {aspect_ratio}"
# Try Gemini first
client = _ensure_client()
if client is None:
logger.warning("Gemini client not available or API key missing")
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
logger.info("Falling back to Imagen API")
return _generate_imagen_images_base64(prompt, aspect_ratio)
return []
retry = 0
delay = initial_retry_delay
while retry <= max_retries:
try:
response = client.models.generate_content(
model="gemini-2.0-flash-exp-image-generation",
contents=[prompt],
)
images_b64: List[str] = []
for part in response.candidates[0].content.parts:
if getattr(part, 'inline_data', None) is not None:
# part.inline_data.data is bytes (base64 decoded by SDK?)
# Standardize to base64 string for API consumers
raw = part.inline_data.data
if isinstance(raw, bytes):
images_b64.append(base64.b64encode(raw).decode('utf-8'))
else:
# Some SDKs may already present base64 str
images_b64.append(str(raw))
if images_b64:
logger.info(f"✅ Gemini generated {len(images_b64)} images successfully")
return images_b64
else:
logger.warning("Gemini returned no images, falling back to Imagen")
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
return _generate_imagen_images_base64(prompt, aspect_ratio)
return []
except Exception as e:
msg = str(e)
logger.warning(f"Gemini image gen error: {msg}")
# Check if this is a quota/API error that warrants fallback
if any(error_type in msg.lower() for error_type in [
'quota', 'resource_exhausted', 'rate_limit', 'billing', 'api_key', '403', '429'
]):
logger.info("Gemini quota/API error detected, falling back to Imagen")
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
return _generate_imagen_images_base64(prompt, aspect_ratio)
return []
# For other errors, retry if possible
if "503" in msg and retry < max_retries:
time.sleep(delay)
delay *= 2
retry += 1
continue
# Final fallback for any other errors
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
logger.info("Final fallback to Imagen due to Gemini error")
return _generate_imagen_images_base64(prompt, aspect_ratio)
return []
# If all retries exhausted, fall back to Imagen
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
logger.info("All Gemini retries exhausted, falling back to Imagen")
return _generate_imagen_images_base64(prompt, aspect_ratio)
return []
def generate_gemini_image(
prompt,
keywords=None,
style=None,
focus=None,
enhance_prompt=True,
max_retries=2,
initial_retry_delay=1.0,
aspect_ratio="9:16",
enable_imagen_fallback=True,
):
"""
Backward-compatible wrapper that generates a single image file on disk and returns path.
Now includes Imagen fallback for improved reliability.
Prefer generate_gemini_images_base64 in new code paths.
"""
logger = logging.getLogger('gemini_image_generator')
images = generate_gemini_images_base64(
prompt,
keywords=keywords,
style=style,
focus=focus,
enhance_prompt=enhance_prompt,
aspect_ratio=aspect_ratio,
max_retries=max_retries,
initial_retry_delay=initial_retry_delay,
enable_imagen_fallback=enable_imagen_fallback,
)
if not images:
return None
# Persist first image to file for legacy callers
img_b64 = images[0]
img_bytes = base64.b64decode(img_b64)
img = Image.open(BytesIO(img_bytes))
# Update filename to indicate which API was used
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
if 'imagen' in prompt.lower() or 'fallback' in prompt.lower():
out_name = f'imagen-fallback-image-{timestamp}.png'
else:
out_name = f'gemini-native-image-{timestamp}.png'
try:
img.save(out_name)
# Also call save_generated_image to reuse existing pipeline
save_generated_image({"artifacts": [{"base64": img_b64}]})
logger.info(f"✅ Image saved successfully: {out_name}")
return out_name
except Exception as e:
logger.error(f"❌ Failed to save image: {e}")
return None
def edit_image(image_path, prompt, max_retries=2, initial_retry_delay=1.0):
"""
- Image editing (text and image to image)
Example prompt: "Edit this image to make it look like a cartoon"
Example prompt: [image of a cat] + [image of a pillow] + "Create a cross stitch of my cat on this pillow."
- Multi-turn image editing (chat)
Example prompts: [upload an image of a blue car.] "Turn this car into a convertible." "Now change the color to yellow."
Image editing with Gemini
To perform image editing, add an image as input.
The following example demonstrats uploading base64 encoded images.
For multiple images and larger payloads, check the image input section.
Args:
image_path (str): The path to the image to edit.
prompt (str): The prompt to edit the image with.
max_retries (int, optional): Maximum number of retry attempts for handling 503 errors. Defaults to 3.
initial_retry_delay (int, optional): Initial delay in seconds before retrying. Defaults to 2.
Returns:
str: The path to the edited image.
"""
import PIL.Image
image = PIL.Image.open(image_path)
retry_count = 0
retry_delay = initial_retry_delay
while retry_count <= max_retries:
try:
client = _ensure_client()
if client is None:
return None
text_input = (prompt)
logger.info("Sending request to Gemini API for image editing")
response = client.models.generate_content(
model="gemini-2.0-flash-exp-image-generation",
contents=[text_input, image],
config=types.GenerateContentConfig(
response_modalities=['Text', 'Image']
)
)
logger.info("Received response from Gemini API for image editing")
edited_img_name = None
for part in response.candidates[0].content.parts:
if getattr(part, 'inline_data', None) is not None:
logger.info("Received edited image data from Gemini")
edited_image = Image.open(BytesIO(part.inline_data.data))
# Save the edited image
edited_img_name = f'edited-{os.path.basename(image_path)}'
try:
logger.info(f"Saving edited image to: {edited_img_name}")
edited_image.save(edited_img_name)
# Create a dictionary with the expected format for save_generated_image
img_response = {
"artifacts": [
{
"base64": base64.b64encode(open(edited_img_name, "rb").read()).decode('utf-8')
}
]
}
# Call save_generated_image with the correct format
save_generated_image(img_response)
except Exception as err:
logger.error(f"Failed to save edited image: {err}")
logger.info(f"Image editing completed. Edited image name: {edited_img_name}")
return edited_img_name
except Exception as err:
error_message = str(err)
logger.error(f"Error in edit_image: {err}")
# Retry on transient 503
if "503" in error_message and retry_count < max_retries:
retry_count += 1
logger.info(f"Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})")
time.sleep(retry_delay)
# Exponential backoff
retry_delay *= 2
else:
return None
# If we've exhausted all retries
return None

View File

@@ -1,69 +0,0 @@
# Ensure you sign up for an account to obtain an API key:
# https://platform.stability.ai/
# Your API key can be found here after account creation:
# https://platform.stability.ai/account/keys
import os
import requests
import base64
from PIL import Image
from io import BytesIO
import streamlit as st
from loguru import logger
# Import APIKeyManager
from ...onboarding.api_key_manager import APIKeyManager
def save_generated_image(data):
"""Save the generated image to a file."""
# Implementation for saving image
pass
def generate_stable_diffusion_image(prompt):
engine_id = "stable-diffusion-xl-1024-v1-0"
api_host = os.getenv('API_HOST', 'https://api.stability.ai')
# Use APIKeyManager instead of direct environment variable access
api_key_manager = APIKeyManager()
api_key = api_key_manager.get_api_key("stability")
if api_key is None:
st.warning("Missing Stability API key. Please configure it in the onboarding process.")
return None
response = requests.post(
f"{api_host}/v1/generation/{engine_id}/text-to-image",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {api_key}"
},
json={
"text_prompts": [
{
"text": prompt
}
],
"cfg_scale": 7,
"height": 1024,
"width": 1024,
"samples": 1,
"steps": 30,
},
)
if response.status_code != 200:
raise Exception("Non-200 response: " + str(response.text))
data = response.json()
img_path = save_generated_image(data)
for i, image in enumerate(data["artifacts"]):
# Decode base64 image data
img_data = base64.b64decode(image["base64"])
# Open image using PIL
img = Image.open(BytesIO(img_data))
# Display the image
img.show()
return img_path

View File

@@ -1,51 +0,0 @@
from loguru import logger
import sys
from PIL import Image
from openai import OpenAI
def gen_new_from_given_img(img_path, image_dir, num_img=1, img_size="1024x1024", response_format="url"):
"""
Generates variations of a given image using OpenAI's image variation API.
This function takes an existing image, processes it, and generates a specified number of new images based on it.
These generated images are variations of the original, providing creative flexibility.
Args:
img_path (str): Path to the original image file.
image_dir (str): Directory where the generated images will be saved.
num_img (int, optional): Number of image variations to generate. Defaults to 1.
img_size (str, optional): Size of the generated images. Defaults to "1024x1024".
response_format (str, optional): Format in which the generated images are returned. Defaults to "url".
Returns:
str: Path to the saved image variation.
Raises:
SystemExit: If a critical error occurs that prevents successful execution.
"""
try:
logger.info(f"Starting image variation generation for: {img_path}")
# Convert and prepare the image
png = Image.open(img_path).convert('RGBA')
background = Image.new('RGBA', png.size, (255, 255, 255))
alpha_composite = Image.alpha_composite(background, png)
alpha_composite.save(img_path, 'PNG', quality=80)
logger.info("Image prepared for variation generation.")
client = OpenAI()
variation_response = client.images.create_variation(
image=open(img_path, "rb", encoding="utf-8"),
n=num_img,
size=img_size,
response_format=response_format
)
# Saving the generated image
generated_image_path = save_generated_image(variation_response, image_dir)
logger.info(f"Image variation generated and saved to: {generated_image_path}")
return generated_image_path
except Exception as e:
logger.error(f"Error occurred during image variation generation: {e}")
sys.exit(f"Exiting due to critical error: {e}")

View File

@@ -1,162 +0,0 @@
#########################################################
#
# This module will generate images for the blogs using APIs
# from Dall-E and other free resources. Given a prompt, the
# images will be stored in local directory.
# Required: openai API key.
#
#########################################################
# imports
import os
import sys
import datetime
import streamlit as st
import openai # OpenAI Python library to make API calls
from loguru import logger
from utils.logger_utils import get_service_logger
# Use service-specific logger to avoid conflicts
logger = get_service_logger("text_to_image_generation")
#from .gen_dali2_images
from .gen_dali3_images import generate_dalle3_images
from .gen_stabl_diff_img import generate_stable_diffusion_image
from ..text_generation.main_text_generation import llm_text_gen
from .gen_gemini_images import generate_gemini_image
def generate_image(user_prompt, title=None, description=None, tags=None, content=None, aspect_ratio="16:9"):
"""
The generation API endpoint creates an image based on a text prompt.
Required inputs:
prompt (str): A text description of the desired image(s). The maximum length is 1000 characters.
Optional inputs:
--> image_engine: dalle2, dalle3, stable diffusion are supported.
--> num_images (int): The number of images to generate. Must be between 1 and 10. Defaults to 1.
--> size (str): The size of the generated images. Must be one of "256x256", "512x512", or "1024x1024".
Smaller images are faster. Defaults to "1024x1024".
-->response_format (str): The format in which the generated images are returned.
Must be one of "url" or "b64_json". Defaults to "url".
--> user (str): A unique identifier representing your end-user, which will help OpenAI to monitor and detect abuse.
--> aspect_ratio (str): The aspect ratio for the generated image. Must be one of "16:9", "4:3", or "1:1". Defaults to "16:9".
"""
# FIXME: Need to remove default value to match sidebar input.
image_engine = 'Gemini-AI'
image_stored_at = None
if user_prompt:
try:
# Use enhanced prompt generator with all available parameters
img_prompt = generate_enhanced_img_prompt(user_prompt, title, description, tags, content)
# Add aspect ratio to the prompt
if aspect_ratio:
img_prompt += f"\n\nAspect ratio: {aspect_ratio}"
if 'Dalle3' in image_engine:
logger.info(f"Calling Dalle3 text-to-image with prompt: {img_prompt}")
image_stored_at = generate_dalle3_images(img_prompt)
elif 'Stability-AI' in image_engine:
logger.info(f"Calling Stable diffusion text-to-image with prompt: \n{img_prompt}")
image_stored_at = generate_stable_diffusion_image(img_prompt)
elif 'Gemini-AI' in image_engine:
logger.info(f"Calling Gemini text-to-image with prompt: \n{img_prompt}")
image_stored_at = generate_gemini_image(img_prompt, aspect_ratio=aspect_ratio)
return image_stored_at
except Exception as err:
logger.error(f"Failed to generate Image: {err}")
st.warning(f"Failed to generate Image: {err}")
else:
logger.error("Skipping Image creation, No prompt provided.")
def generate_img_prompt(user_prompt):
"""
Given prompt, this functions generated a prompt for image generation.
"""
prompt = f"""
As an expert prompt generator for AI text to image models and artist, I will provide you with 'user text' for creating images.
Your task is to create a prompt for a highly relevant image from given 'user text'.
\n
Choose from various art styles, utilize light & shadow effects etc.
Make sure to avoid common image generation mistakes.
Reply with only one answer, no descrition and in plaintext.
Make sure your prompt is detailed and creative descriptions that will inspire unique and interesting images from the AI.
\n\nuser text:
'''{user_prompt}'''"""
response = llm_text_gen(prompt)
return response
def generate_enhanced_img_prompt(user_prompt, title=None, description=None, tags=None, content=None):
"""
Given user prompt and additional context (title, description, tags, content),
this function generates an enhanced prompt for better image generation.
Args:
user_prompt (str): Base prompt from the user
title (str, optional): Blog title or content title
description (str, optional): Blog or content description/summary
tags (list, optional): List of tags related to the content
content (str, optional): Actual content or excerpt
Returns:
str: Enhanced prompt for image generation
"""
# Start with the base prompt
context_parts = [user_prompt]
# Add relevant context if available
if title:
context_parts.append(f"Title: {title}")
if description:
context_parts.append(f"Description: {description}")
if tags and len(tags) > 0:
tag_text = ", ".join(tags[:5]) # Limit to 5 tags to avoid too much noise
context_parts.append(f"Tags: {tag_text}")
# Create a combined context
combined_context = "\n".join(context_parts)
# Add some content excerpt if available (limited to avoid token limits)
content_excerpt = ""
if content:
# Just use the first few hundred characters as excerpt
content_excerpt = content[:300] + "..." if len(content) > 300 else content
# Create the prompt for LLM
prompt = f"""
As an expert prompt engineer for AI image generation models, create a detailed, creative prompt
for generating a high-quality, relevant image based on the following context:
{combined_context}
Additional content excerpt:
{content_excerpt}
Your task is to:
1. Analyze the context and content to understand the main theme and subject
2. Create a rich, detailed prompt for image generation (50-75 words)
3. Include specific visual details, art style, mood, lighting, composition
4. Make sure the prompt is highly relevant to the original context
5. Avoid prohibited content or anything that violates image generation guidelines
Reply with ONLY the final prompt. No explanations or other text.
"""
# Generate the enhanced prompt
try:
enhanced_prompt = llm_text_gen(prompt)
logger.info(f"Generated enhanced image prompt: {enhanced_prompt[:100]}...")
return enhanced_prompt
except Exception as e:
logger.error(f"Error generating enhanced prompt: {e}")
# Fall back to the simple prompt generation if enhanced fails
return generate_img_prompt(user_prompt)

View File

@@ -1,39 +0,0 @@
import base64
import datetime
import os
import requests
from PIL import Image
import logging
def save_generated_image(img_generation_response):
"""
Save generated images for blog, ensuring unique names for SEO.
"""
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Get image save directory with fallback to a local directory
image_save_dir = os.getenv('IMG_SAVE_DIR', 'generated_images')
# Create the directory if it doesn't exist
if not os.path.exists(image_save_dir):
logger.info(f"Creating image save directory: {image_save_dir}")
os.makedirs(image_save_dir, exist_ok=True)
generated_image_name = f"generated_image_{datetime.datetime.now():%Y-%m-%d-%H-%M-%S}.webp"
generated_image_filepath = os.path.join(image_save_dir, generated_image_name)
try:
for i, image in enumerate(img_generation_response["artifacts"]):
with open(generated_image_filepath, "wb") as f:
f.write(base64.b64decode(image["base64"]))
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get generated image content: {e}")
return None
except Exception as e:
logger.error(f"Error saving image: {e}")
return None
logger.info(f"Saved image at path: {generated_image_filepath}")
return generated_image_filepath

View File

@@ -1,16 +1,13 @@
import os
import asyncio
import concurrent.futures
from typing import Any, Dict, List
from dataclasses import dataclass
import requests
from loguru import logger
import time
import random
try:
from google import genai
GOOGLE_GENAI_AVAILABLE = True
except Exception:
GOOGLE_GENAI_AVAILABLE = False
from services.llm_providers.main_text_generation import llm_text_gen
@dataclass
@@ -29,17 +26,10 @@ class WritingAssistantService:
def __init__(self) -> None:
self.exa_api_key = os.getenv("EXA_API_KEY")
self.gemini_api_key = os.getenv("GEMINI_API_KEY")
if not self.exa_api_key:
logger.warning("EXA_API_KEY not configured; writing assistant will fail")
if not (GOOGLE_GENAI_AVAILABLE and self.gemini_api_key):
logger.warning("Gemini not available; writing assistant will fail")
self.gemini_client = None
else:
self.gemini_client = genai.Client(api_key=self.gemini_api_key)
self.http_timeout_seconds = 15
# COST CONTROL: Daily usage limits
@@ -151,9 +141,6 @@ class WritingAssistantService:
raise
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]]) -> tuple[str, float]:
if not self.gemini_client:
raise Exception("Gemini client not available")
# Build compact sources context block
source_blocks: List[str] = []
for i, s in enumerate(sources[:5]):
@@ -164,12 +151,12 @@ class WritingAssistantService:
)
sources_text = "\n\n".join(source_blocks) if source_blocks else "(No sources)"
# Based on Exa demo guidance for completion-only behavior and inline citations
# Provider-agnostic behavior: short continuation with one inline citation hint
system_prompt = (
"You are an essay-completion bot that completes a sentence or continues prose. "
"You are an assistive writing continuation bot. "
"Only produce 1-2 SHORT sentences. Do not repeat or paraphrase the user's stub. "
"Continue in the same tone and topic as the stub. Prefer concrete, current facts from the provided sources. "
"Include exactly one brief, verifiable citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
"Match tone and topic. Prefer concrete, current facts from the provided sources. "
"Include exactly one brief citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
)
user_prompt = (
@@ -179,17 +166,20 @@ class WritingAssistantService:
)
try:
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as executor:
resp = await loop.run_in_executor(
executor,
lambda: self.gemini_client.models.generate_content(
model="gemini-1.5-flash", contents=f"{system_prompt}\n\n{user_prompt}"
),
)
suggestion = (resp.text or "").strip()
# Inter-call jitter to reduce burst rate limits
time.sleep(random.uniform(0.05, 0.15))
ai_resp = llm_text_gen(
prompt=user_prompt,
json_struct=None,
system_prompt=system_prompt,
)
if isinstance(ai_resp, dict) and ai_resp.get("text"):
suggestion = (ai_resp.get("text", "") or "").strip()
else:
suggestion = (str(ai_resp or "")).strip()
if not suggestion:
raise Exception("Gemini returned empty suggestion")
raise Exception("Assistive writer returned empty suggestion")
# naive confidence from number of sources present
confidence = 0.7 if sources else 0.5
return suggestion, confidence

View File

@@ -24,27 +24,100 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
## How It Works
### Simple 4-Step Process
### Complete 6-Phase Workflow
1. **Research Your Topic** - Enter your topic and keywords, then let AI research the latest information
2. **Create an Outline** - AI generates a content outline that you can customize and refine
3. **Write Section by Section** - Generate content for each section using AI, then edit as needed
4. **Optimize and Publish** - Review SEO suggestions, make final edits, and publish your content
ALwrity Blog Writer transforms your ideas into publish-ready content through a sophisticated, AI-powered workflow that ensures quality, accuracy, and SEO optimization at every step.
```mermaid
flowchart TD
A[Start: Keywords & Topic] --> B[Phase 1: Research & Strategy]
B --> C[Phase 2: Intelligent Outline]
C --> D[Phase 3: Content Generation]
D --> E[Phase 4: SEO Analysis]
E --> F[Phase 5: SEO Metadata]
F --> G[Phase 6: Publish & Distribute]
B --> B1[Google Search Grounding]
B --> B2[Competitor Analysis]
B --> B3[Keyword Intelligence]
C --> C1[AI Outline Generation]
C --> C2[Source Mapping]
C --> C3[Title Generation]
D --> D1[Section-by-Section Writing]
D --> D2[Context Memory]
D --> D3[Flow Analysis]
E --> E1[SEO Scoring]
E --> E2[Actionable Recommendations]
E --> E3[AI-Powered Refinement]
F --> F1[Comprehensive Metadata]
F --> F2[Open Graph & Twitter Cards]
F --> F3[Schema.org Markup]
G --> G1[Multi-Platform Publishing]
G --> G2[Scheduling]
G --> G3[Version Management]
style A fill:#e3f2fd
style B fill:#e8f5e8
style C fill:#fff3e0
style D fill:#fce4ec
style E fill:#f1f8e9
style F fill:#e0f2f1
style G fill:#f3e5f5
```
#### Phase 1: Research & Strategy
AI-powered comprehensive research with Google Search grounding, competitor analysis, and keyword intelligence.
#### Phase 2: Intelligent Outline
AI-generated outlines with source mapping, grounding insights, and optimization recommendations.
#### Phase 3: Content Generation
Section-by-section content generation with SEO optimization, context memory, and engagement improvements.
#### Phase 4: SEO Analysis
Advanced SEO analysis with actionable recommendations and AI-powered optimization.
#### Phase 5: SEO Metadata
Optimized metadata generation for titles, descriptions, Open Graph, Twitter Cards, and structured data.
#### Phase 6: Publish & Distribute
Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling capabilities.
### Phase Features At a Glance
| Phase | Key Features | Target Benefits | Best For |
|-------|-------------|-----------------|----------|
| **Phase 1: Research** | Google Search grounding, Competitor analysis, Keyword intelligence, Content angles | Comprehensive data, Time savings, Market insights | All content creators |
| **Phase 2: Outline** | AI generation, Source mapping, Interactive refinement, Title suggestions | Structured content, SEO foundation, Editorial flexibility | Professional writers |
| **Phase 3: Content** | Context-aware writing, Flow analysis, Source integration, Medium mode | High quality, Consistency, Citation accuracy | Content teams |
| **Phase 4: SEO** | Multi-dimensional scoring, Actionable recommendations, AI refinement | Search visibility, Competitive edge, Performance tracking | SEO professionals |
| **Phase 5: Metadata** | Comprehensive SEO tags, Social optimization, Schema markup, Multi-format export | Complete optimization, Rich snippets, Cross-platform readiness | Digital marketers |
| **Phase 6: Publish** | Multi-platform support, Scheduling, Version management, Analytics integration | Efficiency, Strategic timing, Quality control | Solopreneurs & teams |
### What Happens Behind the Scenes
- **Research Phase**: AI searches the web for current information and sources
- **Outline Generation**: Creates a logical structure with headings and key points
- **Content Writing**: Generates engaging, informative content for each section
- **Quality Checks**: Runs fact-checking and SEO analysis automatically
- **Publishing**: Formats content for your chosen platform
The Blog Writer leverages sophisticated AI orchestration to ensure quality at every stage:
- **Research Phase**: AI searches the web using Gemini's native Google Search integration for current, credible information and sources
- **Outline Generation**: Creates logical structure with headings, key points, and source mapping using parallel processing
- **Content Writing**: Generates engaging, context-aware content for each section with continuity tracking and flow analysis
- **SEO Optimization**: Runs comprehensive analysis with parallel non-AI analyzers plus AI insights for actionable recommendations
- **Metadata Generation**: Creates complete SEO metadata package with social media optimization in 2 AI calls maximum
- **Publishing**: Formats content for your chosen platform with scheduling and version management
### User-Friendly Features
- **Progress Tracking**: See real-time progress for research and writing tasks
- **Visual Editor**: Edit content with an easy-to-use WYSIWYG interface
- **Title Suggestions**: Choose from AI-generated title options
- **SEO Integration**: Get SEO suggestions as you write
- **Progress Tracking**: See real-time progress for all long-running tasks with detailed status updates
- **Visual Editor**: Easy-to-use WYSIWYG interface with markdown support and live preview
- **Title Suggestions**: Multiple AI-generated, SEO-scored title options to choose from
- **SEO Integration**: Comprehensive analysis with one-click "Apply Recommendations" for instant optimization
- **Context Memory**: Intelligent continuity tracking across sections for consistent, flowing content
- **Source Attribution**: Automatic citation integration with research source mapping
## Content Types
@@ -130,30 +203,124 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
## Advanced Features
### Content Templates
- **Industry-specific**: Tailored templates
- **Content Types**: Various formats
- **Brand Guidelines**: Consistent styling
- **Custom Templates**: Personalized formats
### ✨ Assistive Writing & Quick Edits
- **Continue Writing**: AI-powered contextual suggestions as you type
- **Smart Typing Assist**: Automatic suggestions after 20+ words
- **Quick Edit Options**: Improve, expand, shorten, professionalize, add transitions, add data
- **Real-time Assistance**: Instant writing help without interrupting your flow
- **Cost-Optimized**: First suggestion automatic, then manual "Continue Writing" for efficiency
- **One-Click Improvements**: Select text and apply quick edits instantly
### Collaboration Tools
- **Team Editing**: Multiple contributors
- **Version Control**: Content history
- **Comments**: Feedback system
- **Approval Workflow**: Review process
### 🔍 Fact-Checking & Quality Assurance
- **Hallucination Detection**: AI-powered verification of claims and facts
- **Source Verification**: Automatic cross-checking against research sources
- **Claim Analysis**: Detailed assessment of each verifiable statement
- **Evidence Support**: Links to supporting or refuting sources
- **Quality Scoring**: Overall confidence metrics for content accuracy
### Automation
- **Scheduled Publishing**: Automated posting
- **Content Calendar**: Planning tools
- **Social Sharing**: Auto-distribution
- **Performance Monitoring**: Analytics tracking
### 🖼️ Image Generation
- **Section-Specific Images**: Generate images per blog section from the outline
- **AI-Powered Prompts**: Auto-suggest images based on section content
- **Advanced Options**: Stability AI, Hugging Face, Gemini
- **Blog Optimization**: Sizes and formats for platform publishing
- **Integrated Workflow**: Generate inside the outline editor
### 📝 SEO Metadata Generation
- **Comprehensive Package**: Title, description, tags, categories, hashtags in 2 AI calls
- **Social Optimization**: Open Graph & Twitter Cards
- **Structured Data**: Schema.org JSON-LD for rich snippets
- **Multi-Format Export**: WordPress, Wix, HTML, JSON-LD
- **Live Preview**: Google, Facebook, Twitter
### Automation & Integration
- **Multi-Platform Publishing**: One-click to WordPress, Wix, Medium
- **Version Management**: Track changes and revisions
- **Scheduled Publishing**: Set future publish dates
- **Google Analytics Integration**: Track content performance
- **Search Console**: Monitor search visibility
## Who Benefits Most
### For Technical Content Writers
- **Research Automation**: Save hours of manual research with AI-powered Google Search grounding
- **Source Attribution**: Automatic citation management and credibility scoring
- **Quality Assurance**: Built-in fact-checking and hallucination detection
- **Citation Integration**: Seamless source references throughout content
### For Solopreneurs
- **Time Efficiency**: Complete blog creation workflow in minutes instead of hours
- **SEO Expertise**: Professional-grade optimization without hiring specialists
- **Multi-Platform Publishing**: One workflow, multiple destinations (WordPress, Wix, Medium)
- **Scheduling & Automation**: Strategic content distribution and timing optimization
### For Digital Marketing & SEO Professionals
- **Comprehensive SEO**: Multi-dimensional scoring with actionable insights
- **Competitive Intelligence**: AI-powered competitor analysis and content gap identification
- **Performance Tracking**: Integration with Google Analytics and Search Console
- **ROI Optimization**: Data-driven content strategy and performance analytics
## How to Use Advanced Features
### Using Assistive Writing (Continue Writing)
```mermaid
flowchart LR
A[Start Typing] -->|20+ words| B[Auto Suggestion]
B --> C{Accept or Reject?}
C -->|Accept| D[Suggestion Inserted]
C -->|Reject| E[Dismiss Suggestion]
D --> F[Continue Writing Button]
E --> F
F -->|Click| G[Manual Suggestion]
style A fill:#e3f2fd
style B fill:#e8f5e8
style G fill:#fff3e0
```
**Quick Steps - Continue Writing:**
1. Type 20+ words in any blog section
2. First suggestion appears automatically below your text
3. Click **"Accept"** to insert or **"Dismiss"** to skip
4. Use **"✍️ Continue Writing"** for more suggestions
5. Suggestions include source citations for fact-checking
**Quick Steps - Text Selection Edits:**
1. Select any text in your content
2. Context menu appears automatically
3. Choose quick edit: **Improve**, **Expand**, **Shorten**, **Professionalize**, **Add Transition**, or **Add Data**
4. Text updates instantly with your selected improvement
### Using Fact-Checking
1. Select a paragraph or claim in your blog content
2. Right-click to open context menu
3. Click **"🔍 Fact Check"**
4. Wait 15-30 seconds for analysis
5. Review results: claims, confidence, supporting/refuting sources
6. Click **"Apply Fix"** to insert source links
### Using Image Generation
1. In **Phase 2: Intelligent Outline**, click **"🖼️ Generate Image"** on any section
2. Modal opens with auto-generated prompt (editable)
3. Click **"Suggest Prompt"** for AI-optimized suggestions
4. Optionally open **"Advanced Image Options"**
5. Generate image (Stability AI, Hugging Face, or Gemini)
6. Image auto-inserts into outline and metadata
### Using SEO Metadata Generation
1. In **Phase 5: SEO Metadata**, open the modal
2. Click **"Generate All Metadata"** (max 2 AI calls)
3. Review tabs: Preview, Core, Social, Structured Data
4. Edit any field; previews update live
5. Copy formats for WordPress, Wix, or custom
6. Images from Phase 2 auto-fill Open Graph
## Getting Started
1. **[Research Integration](research.md)** - Set up automated research
2. **[SEO Analysis](seo-analysis.md)** - Configure SEO optimization
3. **[Implementation Spec](implementation-spec.md)** - Technical details
4. **[Best Practices](../../guides/best-practices.md)** - Optimization tips
1. **[Research Integration](research.md)** - Comprehensive Phase 1 research capabilities
2. **[Workflow Guide](workflow-guide.md)** - Step-by-step 6-phase workflow walkthrough
3. **[SEO Analysis](seo-analysis.md)** - Phase 4 & 5 optimization strategies
4. **[Implementation Spec](implementation-spec.md)** - Technical architecture and API details
5. **[Best Practices](../../guides/best-practices.md)** - Advanced optimization tips
## Related Features

View File

@@ -1,18 +1,44 @@
# Research Integration
# Phase 1: Research & Strategy
ALwrity's Blog Writer includes powerful research integration capabilities that automatically gather, analyze, and verify information to create well-researched, accurate, and comprehensive blog content.
ALwrity's Blog Writer Phase 1 provides powerful AI-powered research capabilities that automatically gather, analyze, and verify information to create well-researched, accurate, and comprehensive blog content. This foundation phase sets the stage for all subsequent content creation.
## What is Research Integration?
## Overview
Research Integration is an AI-powered feature that automatically conducts comprehensive research on your chosen topic, gathering information from multiple sources, verifying facts, and organizing insights to support your content creation process.
Phase 1: Research & Strategy leverages Gemini's native Google Search grounding to conduct comprehensive topic research in a single API call, delivering competitor intelligence, keyword analysis, and content angles to inform your entire blog creation process.
### Key Benefits
- **Comprehensive Research**: Gather information from multiple reliable sources
- **Fact Verification**: Verify claims and statistics automatically
- **Source Attribution**: Provide proper citations and references
- **Trend Analysis**: Identify current trends and developments
- **Competitive Intelligence**: Analyze competitor content and strategies
- **Comprehensive Research**: Gather information from multiple reliable sources with Google Search grounding
- **Competitive Intelligence**: Identify content gaps and opportunities through competitor analysis
- **Keyword Intelligence**: Discover primary, secondary, and long-tail keyword opportunities
- **Content Angles**: AI-generated unique content angles for maximum engagement
- **Time Efficiency**: Complete research in 30-60 seconds with intelligent caching
## Research Data Flow
```mermaid
flowchart LR
A[User Input:<br/>Keywords + Topic] --> B[Phase 1: Research]
B --> C{Cache Check}
C -->|Hit| D[Return Cached<br/>Research]
C -->|Miss| E[Google Search<br/>Grounding]
E --> F[Source Extraction]
F --> G[Keyword Analysis]
F --> H[Competitor Analysis]
F --> I[Content Angle<br/>Generation]
G --> J[Research Output]
H --> J
I --> J
D --> J
J --> K[Cache Storage]
J --> L[Phase 2: Outline]
style B fill:#e8f5e8
style E fill:#fff3e0
style J fill:#e3f2fd
style L fill:#fff3e0
```
## Research Process
@@ -36,299 +62,325 @@ Research Integration is an AI-powered feature that automatically conducts compre
}
```
### 2. Multi-Source Research
### 2. Google Search Grounding (Gemini Integration)
#### Web Research
- **Search Engines**: Google, Bing, and specialized search engines
- **News Sources**: Current news and industry updates
- **Blogs and Articles**: Industry blogs and expert articles
- **Forums and Communities**: Reddit, Quora, and professional forums
- **Social Media**: Twitter, LinkedIn, and industry discussions
Phase 1 leverages Gemini's native Google Search grounding to access real-time web data with a single API call, eliminating the need for complex multi-source integrations.
#### Academic Sources
- **Research Papers**: Academic journals and research publications
- **Studies and Reports**: Industry studies and market research
- **White Papers**: Technical and business white papers
- **Case Studies**: Real-world examples and case studies
- **Expert Opinions**: Industry expert insights and analysis
#### Single API Call Efficiency
- **One Request**: Comprehensive research in a single Gemini API call with Google Search grounding
- **Live Web Data**: Real-time access to current information from the web
- **No Multi-Source Setup**: Eliminates need for multiple API integrations
- **Cost Effective**: Optimized token usage with focused research prompts
- **Caching Intelligence**: Automatic cache storage for repeat keyword research
#### Industry Sources
#### Research Sources (via Google Search)
The research prompt instructs Gemini to gather information from:
- **Current News**: Latest industry news and developments (2024-2025)
- **Industry Reports**: Market research and industry analysis
- **Company Publications**: Official company blogs and reports
- **Professional Networks**: LinkedIn articles and professional content
- **Trade Publications**: Industry-specific magazines and journals
- **Conference Materials**: Industry conference presentations and papers
- **Expert Articles**: Authoritative blogs and professional content
- **Academic Sources**: Research papers and studies
- **Case Studies**: Real-world examples and implementations
- **Statistics**: Key data points and numerical insights
- **Trends**: Current market trends and forecasts
### 3. Information Processing
#### Google Search Grounding Example
```python
research_prompt = """
Research the topic "AI in Digital Marketing" in the technology industry for digital marketers.
#### Data Collection
- **Content Extraction**: Extract relevant information from sources
- **Fact Identification**: Identify key facts, statistics, and claims
- **Quote Collection**: Gather relevant quotes and expert opinions
- **Trend Identification**: Identify current trends and patterns
- **Gap Analysis**: Find information gaps and opportunities
Provide comprehensive analysis including:
1. Current trends and insights (2024-2025)
2. Key statistics and data points with sources
3. Industry expert opinions and quotes
4. Recent developments and news
5. Market analysis and forecasts
6. Best practices and case studies
7. Keyword analysis: primary, secondary, and long-tail opportunities
8. Competitor analysis: top players and content gaps
9. Content angle suggestions: 5 compelling angles for blog posts
#### Information Verification
- **Fact Checking**: Verify facts against multiple sources
- **Source Credibility**: Assess source authority and reliability
- **Date Verification**: Ensure information is current and relevant
- **Bias Detection**: Identify potential bias in sources
- **Cross-Reference**: Cross-reference information across sources
Focus on factual, up-to-date information from credible sources.
"""
```
## Research Features
### 3. Competitor Analysis
### Real-Time Research
The research phase automatically identifies competing content and discovers content gaps where your blog can stand out.
#### Live Data Access
- **Current Information**: Access to real-time data and updates
- **Trend Monitoring**: Track current trends and developments
- **News Integration**: Include latest news and updates
- **Social Media Monitoring**: Track social media discussions
- **Market Data**: Access current market information
#### Content Gap Identification
- **Top Competitors**: Identifies the most authoritative content on your topic
- **Coverage Analysis**: Maps what competitors have covered thoroughly vs. superficially
- **Gap Opportunities**: Highlights underexplored angles and missing information
- **Unique Positioning**: Suggests how to differentiate your content
- **Competitive Advantages**: Identifies areas where you can exceed competitor quality
#### Dynamic Updates
- **Content Freshness**: Ensure content includes latest information
- **Trend Integration**: Incorporate current trends and developments
- **News Relevance**: Include relevant recent news
- **Market Updates**: Include current market conditions
- **Expert Insights**: Access latest expert opinions
#### Competitive Intelligence
- **Content Depth**: Analyzes how thoroughly competitors cover topics
- **Keyword Usage**: Identifies keyword strategies in competitor content
- **Content Structure**: Evaluates how competitors organize information
- **Engagement Patterns**: Notes what formats and angles work best
- **Market Positioning**: Understands where competitors sit in the market
### Source Verification
### 4. Keyword Intelligence
#### Credibility Assessment
- **Domain Authority**: Check website authority and credibility
- **Author Credentials**: Verify author expertise and credentials
- **Publication Standards**: Assess publication quality and standards
- **Peer Review**: Check for peer review and validation
- **Fact-Checking**: Cross-reference with fact-checking organizations
Phase 1 provides comprehensive keyword analysis to optimize your content for search engines.
#### Source Diversity
- **Multiple Perspectives**: Include diverse viewpoints and opinions
- **Source Types**: Mix different types of sources
- **Geographic Diversity**: Include international sources
- **Temporal Range**: Include both recent and historical sources
- **Expertise Levels**: Include both expert and general sources
#### Primary, Secondary & Long-Tail Keywords
- **Primary Keywords**: Main topic keywords with highest search volume
- **Secondary Keywords**: Supporting terms that reinforce the main topic
- **Long-Tail Keywords**: Specific, less competitive phrases with high intent
- **Semantic Keywords**: Related terms that search engines associate with your topic
- **Search Intent**: Categorizes keywords by intent (informational, transactional, navigational)
### Fact Checking
#### Keyword Clustering & Grouping
- **Topic Clusters**: Groups related keywords for comprehensive coverage
- **Thematic Organization**: Organizes keywords by content themes
- **Density Recommendations**: Suggests optimal keyword usage throughout content
- **Priority Ranking**: Identifies which keywords to prioritize
- **Competition Analysis**: Assesses difficulty for ranking on each keyword
#### Automated Verification
- **Claim Verification**: Verify specific claims and statements
- **Statistical Validation**: Check statistics and numerical data
- **Quote Verification**: Verify quotes and attributions
- **Date Accuracy**: Ensure dates and timelines are correct
- **Context Validation**: Verify context and interpretation
### 5. Content Angle Generation
#### Manual Review
- **Expert Review**: Human expert review of critical information
- **Quality Assurance**: Manual quality checks and validation
- **Bias Assessment**: Human assessment of potential bias
- **Context Analysis**: Human analysis of context and interpretation
- **Final Validation**: Final human validation of research quality
AI generates unique content angles that make your blog stand out and engage your audience.
## Research Output
#### AI-Generated Angle Suggestions
- **5 Unique Angles**: Provides multiple distinct approaches to your topic
- **Trending Topics**: Identifies currently popular angles and discussions
- **Audience Pain Points**: Maps audience challenges to content angles
- **Viral Potential**: Assesses which angles have high shareability
- **Expert Opinions**: Synthesizes industry expert viewpoints into angles
### Organized Information
#### Content Angle Example
For a topic like "AI in Marketing," research might suggest:
1. **Case Study Angle**: "10 Marketing Agencies Using AI to Double ROI"
2. **Practical Guide Angle**: "Implementing AI Marketing Tools in 2025: A Step-by-Step Roadmap"
3. **Trend Analysis Angle**: "The Future of AI Marketing: What Industry Leaders Predict"
4. **Problem-Solution Angle**: "Common AI Marketing Failures and How to Avoid Them"
5. **Debunking Angle**: "AI Marketing Myths Debunked: What Actually Works in 2025"
#### Structured Data
- **Key Facts**: Organized list of key facts and information
- **Statistics**: Relevant statistics and numerical data
- **Quotes**: Expert quotes and opinions
- **Trends**: Current trends and developments
- **Sources**: Complete source list with citations
### 6. Information Processing
#### Research Summary
- **Executive Summary**: High-level overview of research findings
- **Key Insights**: Main insights and discoveries
- **Trend Analysis**: Analysis of current trends
- **Gap Identification**: Information gaps and opportunities
- **Recommendations**: Research-based recommendations
#### Data Collection & Extraction
- **Source Extraction**: Automatically extracts 10-20 credible sources from Google Search
- **Fact Identification**: Identifies key facts, statistics, and claims with citations
- **Quote Collection**: Gathers relevant expert quotes with attribution
- **Trend Identification**: Highlights current trends and patterns
- **Search Query Tracking**: Tracks AI-generated search queries for transparency
### Source Citations
#### Source Credibility & Verification
- **Automatic Citation**: Extracts source URLs, titles, and metadata for proper attribution
- **Grounding Metadata**: Includes detailed grounding support scores and chunks
- **Source Diversity**: Ensures mix of authoritative sources (academic, industry, news)
- **Credibility Scoring**: Evaluates source authority and reliability
- **Cross-Reference**: Cross-references key facts across multiple sources
#### Citation Format
- **APA Style**: Academic citation format
- **MLA Style**: Modern Language Association format
- **Chicago Style**: Chicago Manual of Style format
- **Custom Format**: Customizable citation format
- **Hyperlinks**: Direct links to source materials
## Research Output Structure
#### Source Information
- **Author Details**: Author name, credentials, and affiliation
- **Publication Information**: Publication name, date, and details
- **URL and Access**: Direct links and access information
- **Credibility Score**: Source credibility assessment
- **Last Updated**: Last update or verification date
### Comprehensive Research Results
## Integration with Content Creation
Phase 1 returns a complete research package that feeds into all subsequent phases:
### Content Planning
#### Structured Data Package
- **Sources**: 10-20 credible research sources with full metadata
- **Keyword Analysis**: Primary, secondary, long-tail, and semantic keywords
- **Competitor Analysis**: Top competing content and identified gaps
- **Content Angles**: 5 unique, AI-generated content approaches
- **Search Queries**: AI-generated search terms for transparency
- **Grounding Metadata**: Detailed grounding support scores and chunks
#### Research-Informed Planning
- **Topic Development**: Develop topics based on research insights
- **Content Structure**: Structure content based on research findings
- **Key Points**: Identify key points from research
- **Supporting Evidence**: Gather supporting evidence and examples
- **Expert Opinions**: Include relevant expert opinions
#### Research Summary Example
```json
{
"success": true,
"sources": [
{
"url": "https://example.com/research",
"title": "AI Marketing Trends 2025",
"credibility_score": 0.92
}
],
"keyword_analysis": {
"primary": ["AI marketing", "artificial intelligence digital marketing"],
"secondary": ["machine learning marketing", "automated advertising"],
"long_tail": ["how to implement AI marketing tools"],
"search_intent": "informational"
},
"competitor_analysis": {
"top_competitors": [...],
"content_gaps": ["practical implementation guides", "cost-benefit analysis"]
},
"suggested_angles": [
"10 Marketing Agencies Using AI to Double ROI",
"Implementing AI Marketing Tools: A Step-by-Step Roadmap"
]
}
```
#### Content Strategy
- **Audience Insights**: Understand audience based on research
- **Competitive Analysis**: Analyze competitor content and strategies
- **Trend Integration**: Incorporate current trends and developments
- **Gap Opportunities**: Identify content gaps and opportunities
- **Value Proposition**: Develop unique value propositions
## Use Cases for Different Audiences
### Content Enhancement
### For Technical Content Writers
**Scenario**: Writing a technical deep-dive on "React Performance Optimization"
#### Evidence-Based Content
- **Factual Accuracy**: Ensure all facts are accurate and verified
- **Statistical Support**: Support claims with relevant statistics
- **Expert Validation**: Include expert opinions and validation
- **Case Studies**: Include relevant case studies and examples
- **Trend Analysis**: Incorporate current trend analysis
**Phase 1 Delivers**:
- Latest React documentation updates and best practices
- GitHub discussions and Stack Overflow solutions for optimization challenges
- Academic research on frontend performance optimization
- Real-world case studies from major tech companies
- Technical keyword opportunities: "React performance hooks", "memoization strategies"
#### Credibility Building
- **Source Attribution**: Proper attribution of all sources
- **Expert Quotes**: Include relevant expert quotes
- **Data Visualization**: Present data in clear, visual formats
- **Transparency**: Show research process and methodology
- **Quality Assurance**: Maintain high quality standards
**Value**: Eliminates hours of manual research across GitHub, documentation, and forums
## Research Tools and Sources
### For Solopreneurs
**Scenario**: Creating content on "Starting an E-commerce Business in 2025"
### Web Research Tools
**Phase 1 Delivers**:
- Current e-commerce market trends and statistics
- Competitor analysis of top e-commerce success stories
- Content gap: most content focuses on "how to start" but lacks "common pitfalls"
- Unique angle: "The 5 Mistakes That Kill 90% of New E-commerce Businesses"
- Long-tail keywords: "start ecommerce business 2025", "ecommerce business ideas"
#### Search Engines
- **Google Search**: Comprehensive web search
- **Bing Search**: Alternative search engine
- **DuckDuckGo**: Privacy-focused search
- **Specialized Search**: Industry-specific search engines
- **Academic Search**: Academic and research databases
**Value**: Provides business intelligence without expensive consultants
#### Research Platforms
- **Google Scholar**: Academic research and papers
- **ResearchGate**: Academic network and research
- **JSTOR**: Academic journal database
- **PubMed**: Medical and scientific research
- **IEEE Xplore**: Technical and engineering research
### For Digital Marketing & SEO Professionals
**Scenario**: Content strategy for "Local SEO Best Practices"
### Industry Sources
**Phase 1 Delivers**:
- Competitor analysis of top-ranking local SEO content
- Keyword gaps: competitors missing "Google Business Profile optimization"
- Trending angles: "Voice search local optimization" and "AI-powered local listings"
- Data-backed insights: "73% of local searches result in store visits"
- Content opportunity: "Local SEO Audit Template" (high search, low competition)
#### Market Research
- **Statista**: Statistical data and market research
- **IBISWorld**: Industry research and analysis
- **McKinsey**: Business and industry insights
- **Deloitte**: Professional services research
- **PwC**: Business and industry analysis
**Value**: Delivers competitive intelligence and keyword strategy in one research pass
#### News and Media
- **Reuters**: International news and analysis
- **Bloomberg**: Business and financial news
- **TechCrunch**: Technology news and analysis
- **Harvard Business Review**: Business insights and analysis
- **MIT Technology Review**: Technology and innovation news
## Performance & Caching
### Intelligent Caching System
Phase 1 implements a dual-layer caching strategy to optimize performance and reduce costs.
#### Cache Storage
- **Persistent Cache**: SQLite database stores research results for exact keyword matches
- **Memory Cache**: In-process cache for faster repeated access within a session
- **Cache Key**: Based on exact keyword match, industry, and target audience
- **Cache Duration**: Results stored indefinitely until invalidated
#### Cache Benefits
- **Cost Reduction**: Avoids redundant API calls for same topics
- **Speed**: Instant results for cached research (0-5 seconds vs. 30-60 seconds)
- **Consistency**: Ensures reproducible research results for same queries
- **Transparency**: Progress messages indicate cache hits: "✅ Using cached research"
### Performance Metrics
**Typical Research Timing**:
- **Cache Hit**: 0-5 seconds (instant return)
- **Fresh Research**: 30-60 seconds (Google Search + AI processing)
- **Sources Found**: 10-20 credible sources per research
- **Search Queries**: 5-10 AI-generated search terms tracked
## Best Practices
### Research Quality
### Effective Research Setup
#### Source Selection
1. **Authority**: Choose authoritative and credible sources
2. **Recency**: Prefer recent and up-to-date information
3. **Relevance**: Ensure sources are relevant to your topic
4. **Diversity**: Include diverse perspectives and sources
5. **Verification**: Cross-reference information across sources
#### Keyword Strategy
1. **Be Specific**: Use 3-5 focused keywords rather than broad topics
2. **Industry Context**: Always specify industry for better context
3. **Audience Definition**: Define target audience clearly for tailored research
4. **Topic Clarity**: Provide a clear, concise topic description
5. **Word Count Target**: Set realistic word count goals (1000-3000 words optimal)
#### Information Processing
1. **Accuracy**: Verify all facts and claims
2. **Context**: Understand context and interpretation
3. **Bias Awareness**: Be aware of potential bias
4. **Completeness**: Ensure comprehensive coverage
5. **Quality**: Maintain high quality standards
#### Research Quality Optimization
1. **Review Sources**: Always review the returned sources for credibility
2. **Use Content Angles**: Leverage AI-generated angles for unique positioning
3. **Explore Competitor Gaps**: Focus on content gaps for competitive advantage
4. **Keyword Variety**: Review all keyword types (primary, secondary, long-tail)
5. **Leverage Caching**: Reuse research for related topics to save time and cost
### Content Integration
### Research-to-Content Pipeline
#### Research Application
1. **Relevance**: Use research that's relevant to your audience
2. **Balance**: Balance different perspectives and opinions
3. **Clarity**: Present research findings clearly
4. **Attribution**: Properly attribute all sources
5. **Value**: Add value through research insights
#### Phase 1 to Phase 2 Transition
1. **Validate Research**: Ensure research has 10+ credible sources before proceeding
2. **Review Angles**: Select compelling content angles for outline inspiration
3. **Check Keywords**: Verify keyword analysis aligns with your SEO goals
4. **Analyze Gaps**: Use competitor analysis to inform unique content positioning
5. **Source Quality**: Confirm grounding metadata shows high credibility scores (0.8+)
#### Quality Assurance
1. **Fact Checking**: Verify all facts and claims
2. **Source Review**: Review and validate all sources
3. **Expert Input**: Seek expert input when needed
4. **Peer Review**: Get peer review of research quality
5. **Continuous Improvement**: Continuously improve research process
## Advanced Features
### Custom Research
#### Research Parameters
- **Custom Sources**: Specify custom source preferences
- **Research Depth**: Adjust research depth and scope
- **Language Settings**: Set research language preferences
- **Date Ranges**: Specify date ranges for research
- **Geographic Focus**: Set geographic focus for research
#### Research Filters
- **Source Types**: Filter by source types and categories
- **Credibility Thresholds**: Set minimum credibility requirements
- **Date Filters**: Filter by publication date
- **Language Filters**: Filter by language
- **Topic Filters**: Filter by topic relevance
### Research Analytics
#### Performance Tracking
- **Research Quality**: Track research quality metrics
- **Source Performance**: Monitor source performance
- **Accuracy Rates**: Track fact-checking accuracy
- **User Satisfaction**: Monitor user satisfaction with research
- **Improvement Areas**: Identify areas for improvement
#### Research Insights
- **Trend Analysis**: Analyze research trends and patterns
- **Source Analysis**: Analyze source performance and quality
- **Content Impact**: Measure impact of research on content
- **Audience Engagement**: Track audience engagement with research
- **ROI Analysis**: Analyze return on research investment
#### Research Output Utilization
1. **Source Mapping**: Use sources strategically across different sections
2. **Keyword Integration**: Naturally integrate primary and secondary keywords
3. **Angles to Sections**: Transform content angles into distinct content sections
4. **Gaps to Value**: Convert content gaps into unique selling propositions
5. **Trend Integration**: Weave current trends naturally throughout content
## Troubleshooting
### Common Issues
### Common Issues & Solutions
#### Research Quality
- **Insufficient Sources**: Add more diverse sources
- **Outdated Information**: Update research with current information
- **Bias Detection**: Address potential bias in sources
- **Fact Verification**: Improve fact-checking process
- **Source Credibility**: Improve source selection criteria
#### Low-Quality Research Results
**Problem**: Research returns fewer than 10 sources or low credibility scores
#### Technical Issues
- **API Connectivity**: Resolve API connection issues
- **Data Processing**: Fix data processing problems
- **Source Access**: Resolve source access issues
- **Performance Issues**: Address performance concerns
- **Integration Problems**: Fix integration issues
**Solutions**:
- **Refine Keywords**: Use more specific, focused keywords
- **Expand Topic**: Broaden topic slightly to increase source pool
- **Adjust Industry**: Ensure industry classification is accurate
- **Check Cache**: Clear cache if you're getting stale results
- **Retry Research**: Google Search grounding may need a second attempt
#### Insufficient Keyword Analysis
**Problem**: Limited keyword variety or missing long-tail opportunities
**Solutions**:
- **Add Topic Context**: Provide more detailed topic description
- **Specify Audience**: Better audience definition improves keyword targeting
- **Increase Word Count**: Target 2000+ words for richer keyword analysis
- **Review Persona Settings**: Industry and audience persona affects keyword discovery
#### Missing Competitor Data
**Problem**: Competitor analysis lacks depth or opportunities
**Solutions**:
- **Use Specific Keywords**: More targeted keywords reveal better competitors
- **Expand Industry Context**: Broad industry understanding improves competitive mapping
- **Review Content Angles**: Angles often highlight what competitors are NOT doing
- **Manual Review**: Top sources list shows main competitors worth reviewing
#### Cache Not Working
**Problem**: Research taking full time even for duplicate keywords
**Solutions**:
- **Check Exact Match**: Keywords, industry, and audience must match exactly
- **Verify Cache**: Check if persistent cache is enabled
- **Clear and Retry**: Sometimes clearing cache helps if data is corrupted
- **Check Logs**: Look for cache hit/miss messages in progress updates
### Getting Help
#### Support Resources
- **Documentation**: Review research integration documentation
- **Tutorials**: Watch research feature tutorials
- **Best Practices**: Follow research best practices
- **Community**: Join user community discussions
- **Support**: Contact technical support
- **Workflow Guide**: [Complete 6-phase walkthrough](workflow-guide.md)
- **API Reference**: [Research API endpoints](api-reference.md)
- **Implementation Spec**: [Technical architecture](implementation-spec.md)
- **Best Practices**: [Advanced optimization tips](../../guides/best-practices.md)
#### Optimization Tips
- **Settings Review**: Regularly review research settings
- **Source Management**: Maintain source quality and diversity
- **Quality Monitoring**: Monitor research quality continuously
- **Performance Tracking**: Track research performance metrics
- **Continuous Improvement**: Continuously improve research process
#### Performance Optimization
- **Use Caching**: Leverage intelligent caching for repeat research
- **Keyword Precision**: More specific keywords yield better results
- **Industry Context**: Always provide industry for better data quality
- **Monitor Progress**: Review progress messages for efficiency insights
- **Batch Research**: Plan multiple blogs to maximize cache benefits
---
*Ready to enhance your content with comprehensive research? [Start with our First Steps Guide](../../getting-started/first-steps.md) and [Explore Blog Writer Features](overview.md) to begin creating well-researched, authoritative content!*
## Next Steps
Now that you understand Phase 1: Research & Strategy, move to the next phase:
- **[Phase 2: Intelligent Outline](workflow-guide.md#phase-2-intelligent-outline)** - Transform research into structured content plans
- **[Complete Workflow Guide](workflow-guide.md)** - End-to-end 6-phase walkthrough
- **[Blog Writer Overview](overview.md)** - Overview of all 6 phases
- **[Getting Started Guide](../../getting-started/quick-start.md)** - Quick start for new users
---
*Ready to leverage Phase 1 research capabilities? Check out the [Workflow Guide](workflow-guide.md) to see how research flows into outline generation and beyond!*

View File

@@ -1,343 +1,478 @@
# SEO Analysis
# SEO Analysis & Optimization (Phase 4 & 5)
ALwrity's Blog Writer includes comprehensive SEO analysis capabilities that automatically optimize your content for search engines, improve readability, and enhance your content's search visibility.
ALwrity's Blog Writer includes comprehensive SEO analysis and metadata generation capabilities across Phases 4 and 5, automatically optimizing your content for search engines and preparing it for publication across platforms.
## What is SEO Analysis?
## Overview
SEO Analysis is an AI-powered feature that evaluates your blog content for search engine optimization, providing detailed insights, recommendations, and automated optimizations to improve your content's search ranking and visibility.
SEO optimization in the Blog Writer happens in two complementary phases:
- **Phase 4: SEO Analysis** - Comprehensive scoring, recommendations, and AI-powered content refinement
- **Phase 5: SEO Metadata** - Complete metadata generation including Open Graph, Twitter Cards, and Schema.org markup
### Key Benefits
- **Search Optimization**: Optimize content for search engines
- **Keyword Analysis**: Analyze and optimize keyword usage
- **Readability Enhancement**: Improve content readability and user experience
- **Technical SEO**: Ensure proper technical SEO implementation
- **Performance Insights**: Track and improve SEO performance
#### Phase 4: SEO Analysis
- **Multi-Dimensional Scoring**: Comprehensive SEO evaluation across 5 key categories
- **Actionable Recommendations**: Priority-ranked improvement suggestions with specific fixes
- **AI-Powered Refinement**: One-click "Apply Recommendations" for instant optimization
- **Parallel Processing**: Fast analysis using parallel non-AI analyzers plus AI insights
- **Performance Tracking**: Track SEO improvements and measure impact
## SEO Analysis Process
#### Phase 5: SEO Metadata
- **Comprehensive Metadata**: Complete SEO metadata package in 2 AI calls maximum
- **Social Optimization**: Open Graph and Twitter Cards for rich social previews
- **Structured Data**: Schema.org markup for enhanced search results and rich snippets
- **Multi-Format Export**: Ready-to-use formats for WordPress, Wix, and custom platforms
- **Platform Integration**: One-click copy and direct platform publishing support
### 1. Content Analysis
## Phase 4: SEO Analysis
Phase 4 provides comprehensive SEO evaluation with actionable recommendations and AI-powered content refinement.
### Parallel Processing Architecture
Phase 4 uses a sophisticated parallel processing approach for speed and accuracy:
```mermaid
flowchart TD
A[Blog Content] --> B[Phase 4: SEO Analysis]
B --> C[Parallel Non-AI Analyzers]
C --> D[Content Structure]
C --> E[Keyword Usage]
C --> F[Readability]
C --> G[Content Quality]
C --> H[Heading Structure]
D --> I[SEO Results]
E --> I
F --> I
G --> I
H --> I
I --> J[Single AI Analysis]
J --> K[Actionable Recommendations]
K --> L[Apply Recommendations]
L --> M[Refined Content]
style A fill:#e3f2fd
style B fill:#f1f8e9
style C fill:#fff3e0
style I fill:#e8f5e8
style L fill:#fce4ec
style M fill:#e1f5fe
```
### Multi-Dimensional SEO Scoring
Phase 4 evaluates your content across 5 key categories:
#### Overall SEO Score
- **Composite Rating**: Overall score (0-100) based on weighted category scores
- **Grade Assignment**: Automatically assigns grades (Excellent/Good/Needs Improvement)
- **Trend Tracking**: Compares to previous analysis to track improvements
- **Visual Feedback**: Color-coded UI provides instant visual assessment
#### Category Breakdown
- **Structure Score**: Heading hierarchy, content organization, section balance
- **Keywords Score**: Keyword density, placement, variation, long-tail usage
- **Readability Score**: Reading level, sentence complexity, clarity assessment
- **Quality Score**: Content depth, engagement potential, value delivery
- **Headings Score**: H1-H3 distribution, keyword integration in headings
### Actionable Recommendations
Phase 4 generates specific, priority-ranked recommendations for improvement.
#### Recommendation Categories
- **High Priority**: Critical SEO issues impacting search visibility
- **Medium Priority**: Significant improvements that boost rankings
- **Low Priority**: Nice-to-have optimizations for fine-tuning
#### Example Recommendations
1. **Structure**: "Add more H2 subheadings to improve content scannability and keyword distribution"
2. **Keywords**: "Increase primary keyword density from 0.8% to 1.5% for optimal SEO performance"
3. **Readability**: "Simplify complex sentences; aim for average 15-20 words per sentence"
4. **Content**: "Add more specific examples and case studies to support key arguments"
5. **Meta**: "Reduce meta description to 155 characters for better search result display"
### AI-Powered Content Refinement
The "Apply Recommendations" feature uses AI to automatically improve your content based on SEO analysis.
#### Intelligent Rewriting
- **Smart Application**: Applies recommendations while preserving your original intent
- **Natural Integration**: Optimizes keywords and structure without sounding forced
- **Context Preservation**: Maintains research accuracy and source alignment
- **Quality Maintenance**: Ensures readability while improving SEO metrics
#### Application Process
```mermaid
flowchart LR
A[Current Content] --> B[SEO Recommendations]
B --> C[AI Prompt Construction]
C --> D[LLM Text Generation]
D --> E[Normalization & Validation]
E --> F[Optimized Content]
style A fill:#e3f2fd
style B fill:#fff3e0
style D fill:#f1f8e9
style F fill:#e8f5e8
```
### Content Analysis Process
#### Initial Assessment
- **Content Structure**: Analyze heading hierarchy and content organization
- **Keyword Density**: Check keyword usage and density
- **Content Length**: Evaluate content length and depth
- **Readability**: Assess content readability and user experience
- **Technical Elements**: Check technical SEO elements
- **Content Structure**: Analyzes heading hierarchy, paragraph distribution, list usage
- **Keyword Distribution**: Maps keyword density and placement across sections
- **Readability Metrics**: Calculates Flesch Reading Ease, sentence length, complexity
- **Quality Indicators**: Evaluates depth, engagement potential, value delivery
- **Technical Elements**: Checks heading structure, meta elements, content length
#### Analysis Parameters
#### Parallel Analysis Details
Each analyzer processes content independently:
- **ContentAnalyzer**: Structure, organization, section balance
- **KeywordAnalyzer**: Density, placement, variation, semantic coverage
- **ReadabilityAnalyzer**: Reading level, sentence complexity, word choice
- **QualityAnalyzer**: Depth, engagement, value, completeness
- **HeadingAnalyzer**: Hierarchy, distribution, keyword integration
Results are combined with AI insights for comprehensive recommendations.
## Phase 5: SEO Metadata Generation
Phase 5 generates comprehensive SEO metadata in maximum 2 AI calls, creating a complete optimization package ready for publication.
### Efficient Two-Call Architecture
Phase 5 minimizes AI calls for cost efficiency while delivering comprehensive metadata:
```mermaid
flowchart TD
A[Blog Content + SEO Analysis] --> B[Phase 5: Metadata Generation]
B --> C{Call 1: Core Metadata}
C --> D[SEO Title]
C --> E[Meta Description]
C --> F[URL Slug]
C --> G[Tags & Categories]
C --> H[Reading Time]
D --> I{Call 2: Social Metadata}
E --> I
F --> I
G --> I
H --> I
I --> J[Open Graph Tags]
I --> K[Twitter Cards]
I --> L[Schema.org JSON-LD]
J --> M[Complete Metadata Package]
K --> M
L --> M
style A fill:#e3f2fd
style B fill:#e0f2f1
style C fill:#fff3e0
style I fill:#fce4ec
style M fill:#e8f5e8
```
### Core Metadata Generation
#### SEO-Optimized Elements
- **SEO Title** (50-60 chars): Front-loaded primary keyword, compelling, click-worthy
- **Meta Description** (150-160 chars): Keyword-rich with strong CTA in first 120 chars
- **URL Slug**: Clean, hyphenated, 3-5 words with primary keyword
- **Blog Tags** (5-8): Mix of primary, semantic, and long-tail keywords
- **Blog Categories** (2-3): Industry-standard classification
- **Social Hashtags** (5-10): Industry-specific with trending terms
- **Reading Time**: Calculated from word count (200 words/minute)
- **Focus Keyword**: Main SEO keyword selection
#### Metadata Personalization
Metadata is dynamically tailored based on:
- Research keywords and search intent
- Target audience and industry
- SEO analysis recommendations
- Blog content structure and outline
- Tone and writing style preferences
### Social Media Optimization
#### Open Graph Tags
- **og:title**: Optimized for social sharing
- **og:description**: Compelling social preview text
- **og:image**: Recommended image dimensions and sources
- **og:type**: Article/blog classification
- **og:url**: Canonical URL reference
#### Twitter Cards
- **twitter:card**: Summary card with large image support
- **twitter:title**: Concise, engaging headline
- **twitter:description**: Twitter-optimized summary
- **twitter:image**: Twitter-specific image optimization
- **twitter:site**: Website Twitter handle integration
### Structured Data (Schema.org)
#### Article Schema
```json
{
"content": "Your blog post content here...",
"target_keywords": ["primary keyword", "secondary keyword"],
"competitor_urls": ["https://competitor1.com", "https://competitor2.com"],
"analysis_depth": "comprehensive",
"optimization_goals": ["rankings", "traffic", "engagement"]
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "SEO-optimized title",
"description": "Meta description",
"author": {
"@type": "Organization",
"name": "Your Brand"
},
"datePublished": "2025-01-20",
"dateModified": "2025-01-20",
"mainEntityOfPage": {
"@type": "WebPage"
}
}
```
### 2. Keyword Analysis
#### Additional Schema Types
- **Organization Markup**: Brand and publisher information
- **Breadcrumb Schema**: Navigation structure for rich snippets
- **FAQ Schema**: Q&A structured data for featured snippets
- **Review Schema**: Ratings and review markup
#### Primary Keywords
- **Keyword Density**: Analyze primary keyword density
- **Keyword Placement**: Check keyword placement and distribution
- **Keyword Variations**: Identify keyword variations and synonyms
- **Long-Tail Keywords**: Analyze long-tail keyword usage
- **Semantic Keywords**: Check semantic keyword integration
### Multi-Format Export
#### Secondary Keywords
- **Related Terms**: Identify related terms and phrases
- **LSI Keywords**: Check latent semantic indexing keywords
- **Contextual Keywords**: Analyze contextual keyword usage
- **Industry Terms**: Include industry-specific terminology
- **User Intent**: Match keywords to user search intent
Phase 5 outputs metadata in multiple formats for different platforms:
### 3. Content Optimization
#### HTML Meta Tags
```html
<meta property="og:title" content="AI in Medical Diagnosis: Transforming Healthcare">
<meta name="description" content="Discover how AI is revolutionizing medical diagnosis...">
<meta name="keywords" content="AI healthcare, medical diagnosis, healthcare technology">
```
#### Structure Analysis
- **Heading Hierarchy**: Check H1, H2, H3 structure
- **Paragraph Length**: Analyze paragraph length and structure
- **List Usage**: Check for bullet points and numbered lists
- **Content Flow**: Analyze content flow and organization
- **Section Balance**: Ensure balanced content sections
#### JSON-LD Structured Data
Ready-to-paste structured data for search engines
#### Readability Assessment
- **Reading Level**: Assess content reading level
- **Sentence Length**: Analyze sentence length and complexity
- **Word Choice**: Check word choice and vocabulary
- **Clarity**: Assess content clarity and understanding
- **Engagement**: Evaluate content engagement potential
#### WordPress Export
WordPress-specific format with Yoast SEO compatibility
## SEO Analysis Features
### Keyword Optimization
#### Keyword Research
- **Primary Keywords**: Identify main target keywords
- **Secondary Keywords**: Find supporting keywords
- **Long-Tail Keywords**: Discover specific, less competitive phrases
- **LSI Keywords**: Find semantically related terms
- **Competitor Keywords**: Analyze competitor keyword usage
#### Keyword Implementation
- **Title Optimization**: Optimize title tags for keywords
- **Meta Description**: Create keyword-rich meta descriptions
- **Heading Tags**: Optimize heading tags for keywords
- **Content Integration**: Naturally integrate keywords into content
- **Internal Linking**: Use keywords in internal links
### Content Structure
#### Heading Optimization
- **H1 Tag**: Single, keyword-rich H1 tag
- **H2 Tags**: Logical H2 tag structure
- **H3 Tags**: Detailed H3 tag organization
- **Heading Balance**: Balanced heading distribution
- **Keyword Integration**: Keywords in relevant headings
#### Content Organization
- **Introduction**: Engaging, keyword-rich introduction
- **Body Sections**: Well-organized body content
- **Conclusion**: Strong, actionable conclusion
- **Call-to-Action**: Clear, compelling CTAs
- **Content Flow**: Smooth content flow and transitions
### Technical SEO
#### Meta Tags
- **Title Tag**: Optimized title tag (50-60 characters)
- **Meta Description**: Compelling meta description (150-160 characters)
- **Meta Keywords**: Relevant meta keywords
- **Open Graph**: Social media optimization tags
- **Schema Markup**: Structured data implementation
#### Content Elements
- **Image Alt Text**: Descriptive alt text for images
- **Internal Links**: Strategic internal linking
- **External Links**: Relevant external link placement
- **URL Structure**: Clean, keyword-rich URLs
- **Content Length**: Optimal content length for SEO
#### Wix Integration
Direct Wix blog API format for seamless publishing
## Analysis Results
### SEO Score
### Phase 4 Output Structure
#### Overall Score
- **SEO Score**: Overall SEO performance score (0-100)
- **Keyword Score**: Keyword optimization score
- **Content Score**: Content quality and structure score
- **Technical Score**: Technical SEO implementation score
- **Readability Score**: Content readability score
Phase 4 returns comprehensive analysis results:
#### Score Breakdown
```json
{
"overall_score": 85,
"keyword_score": 90,
"content_score": 80,
"technical_score": 85,
"readability_score": 88,
"recommendations": [
"Improve meta description length",
"Add more internal links",
"Optimize image alt text"
]
"overall_score": 82,
"grade": "Good",
"category_scores": {
"structure": 85,
"keywords": 88,
"readability": 78,
"quality": 80,
"headings": 84
},
"actionable_recommendations": [
{
"category": "Structure",
"priority": "High",
"recommendation": "Add H2 subheadings to improve scannability",
"impact": "Better keyword distribution and user experience"
},
{
"category": "Readability",
"priority": "Medium",
"recommendation": "Simplify complex sentences (average 20 words)",
"impact": "Improved readability score and engagement"
}
],
"keyword_analysis": {
"primary_keyword_density": 1.2,
"semantic_keyword_count": 15,
"long_tail_usage": 8,
"optimization_status": "Good"
}
}
```
### Detailed Recommendations
## Use Cases for Different Audiences
#### Keyword Optimization
- **Keyword Density**: Adjust keyword density for optimal results
- **Keyword Placement**: Improve keyword placement and distribution
- **Keyword Variations**: Add more keyword variations
- **Long-Tail Keywords**: Include more long-tail keywords
- **Semantic Keywords**: Add semantically related terms
### For Technical Content Writers
**Scenario**: Creating a technical deep-dive on "React Server Components"
#### Content Improvement
- **Heading Structure**: Improve heading hierarchy
- **Paragraph Length**: Optimize paragraph length
- **Content Flow**: Enhance content flow and organization
- **Readability**: Improve content readability
- **Engagement**: Increase content engagement
**Phase 4 Delivers**:
- Structure score analysis: Identifies need for more code examples in H3 sections
- Readability assessment: Detects overly complex technical jargon
- Keyword optimization: Suggests semantic keywords like "React SSR" and "Next.js 13"
- Actionable fix: "Add 'why it matters' explanations for React Server Component concepts"
#### Technical Optimization
- **Meta Tags**: Optimize meta tags and descriptions
- **Image Optimization**: Improve image alt text and optimization
- **Internal Linking**: Add strategic internal links
- **URL Structure**: Optimize URL structure
- **Schema Markup**: Implement structured data
**Phase 5 Delivers**:
- SEO title: "React Server Components Explained: Complete 2025 Guide"
- Meta description: Includes CTA like "Master RSC implementation with practical examples"
- JSON-LD: Code schema markup for search engine code indexing
- Social tags: #React #WebDevelopment #Programming
## Competitive Analysis
**Value**: Technical content optimized for both search engines and developer audiences
### Competitor Comparison
### For Solopreneurs
**Scenario**: Blog on "Starting an Online Course Business"
#### Content Analysis
- **Content Length**: Compare content length with competitors
- **Keyword Usage**: Analyze competitor keyword strategies
- **Content Structure**: Compare content organization
- **Readability**: Assess competitor content readability
- **Engagement**: Compare engagement potential
**Phase 4 Delivers**:
- Quality score: Identifies missing CTA elements in conclusion
- Readability: Highlights need to simplify business jargon
- Keyword gaps: Discovers missing long-tail "online course pricing strategy"
- High-priority fix: "Add specific revenue examples to build credibility"
#### SEO Performance
- **Search Rankings**: Compare search engine rankings
- **Traffic Analysis**: Analyze competitor traffic patterns
- **Backlink Profile**: Compare backlink strategies
- **Social Signals**: Analyze social media performance
- **Content Gaps**: Identify content opportunities
**Phase 5 Delivers**:
- SEO title: "Start Online Course Business: Ultimate 2025 Guide" (56 chars)
- Social hashtags: #OnlineCourses #PassiveIncome #Entrepreneurship
- Schema.org: EducationalCourse schema for course-related rich snippets
- Reading time: "15 minutes" for appropriate audience expectation
### Gap Analysis
**Value**: Professional SEO without hiring expensive consultants
#### Content Opportunities
- **Missing Topics**: Identify topics competitors haven't covered
- **Content Depth**: Find areas for deeper content coverage
- **Keyword Gaps**: Discover keyword opportunities
- **Format Gaps**: Identify content format opportunities
- **Audience Gaps**: Find underserved audience segments
### For Digital Marketing & SEO Professionals
**Scenario**: Strategy content on "Local SEO for Small Businesses"
#### Competitive Advantages
- **Unique Angles**: Develop unique content angles
- **Expertise Showcase**: Highlight unique expertise
- **Better Coverage**: Provide more comprehensive coverage
- **Improved Quality**: Create higher quality content
- **Enhanced User Experience**: Improve user experience
**Phase 4 Delivers**:
- Comprehensive scoring across all 5 categories with detailed breakdown
- Competitor analysis integration from Phase 1 research
- High-priority recommendations: "Missing Google Business Profile optimization section"
- Metrics: Keyword density at 0.9%, target 1.5-2% for competitive keywords
## Performance Tracking
**Phase 5 Delivers**:
- Complete metadata package with local SEO schema markup
- Location-based Open Graph tags for local business visibility
- Multi-format export for WordPress with Yoast compatibility
- Structured data including LocalBusiness schema for local SERP features
### SEO Metrics
#### Search Performance
- **Search Rankings**: Track keyword rankings
- **Organic Traffic**: Monitor organic search traffic
- **Click-Through Rate**: Track search result clicks
- **Impression Share**: Monitor search impression share
- **Average Position**: Track average search position
#### Content Performance
- **Page Views**: Monitor page view metrics
- **Time on Page**: Track user engagement time
- **Bounce Rate**: Monitor bounce rate
- **Conversion Rate**: Track conversion metrics
- **Social Shares**: Monitor social media shares
### Analytics Integration
#### Google Analytics
- **Traffic Sources**: Analyze traffic sources
- **User Behavior**: Track user behavior patterns
- **Content Performance**: Monitor content performance
- **Conversion Tracking**: Track conversion metrics
- **Audience Insights**: Analyze audience demographics
#### Search Console
- **Search Queries**: Monitor search query performance
- **Click Data**: Track click-through rates
- **Impression Data**: Monitor search impressions
- **Position Data**: Track search position changes
- **Coverage Issues**: Identify technical issues
**Value**: Enterprise-grade SEO optimization with detailed analytics
## Best Practices
### Content Optimization
### Phase 4: SEO Analysis Best Practices
#### Keyword Strategy
1. **Primary Focus**: Focus on one primary keyword per page
2. **Natural Integration**: Integrate keywords naturally
3. **Semantic Keywords**: Use semantically related terms
4. **Long-Tail Keywords**: Target specific, long-tail phrases
5. **User Intent**: Match keywords to user search intent
#### Pre-Analysis Preparation
1. **Complete Content**: Ensure all sections are finalized before analysis
2. **Research Integration**: Verify Phase 1 research data includes keywords
3. **Word Count**: Target 1000-3000 words for optimal SEO analysis
4. **Structure Review**: Confirm proper heading hierarchy (H1, H2, H3)
5. **Content Quality**: Ensure content is factually accurate and complete
#### Content Quality
1. **Original Content**: Create original, unique content
2. **Comprehensive Coverage**: Provide comprehensive topic coverage
3. **Expert Authority**: Demonstrate expertise and authority
4. **User Value**: Provide clear value to users
5. **Engagement**: Create engaging, shareable content
#### Using "Apply Recommendations"
1. **Review First**: Always review recommendations before applying
2. **Selective Application**: Consider applying high-priority fixes first
3. **Edit After**: Manually refine AI-applied changes for your voice
4. **Preserve Intent**: Verify AI preserved your original meaning
5. **Re-Analyze**: Run Phase 4 again after applying to track improvement
### Technical SEO
### Phase 5: Metadata Generation Best Practices
#### On-Page Optimization
1. **Title Tags**: Create compelling, keyword-rich titles
2. **Meta Descriptions**: Write engaging meta descriptions
3. **Heading Structure**: Use proper heading hierarchy
4. **Internal Linking**: Implement strategic internal linking
5. **Image Optimization**: Optimize images with alt text
#### Metadata Optimization
1. **Title Length**: Keep SEO titles to 50-60 characters for SERP display
2. **Meta Descriptions**: Write 150-160 character descriptions with CTA in first 120 chars
3. **Keyword Placement**: Front-load primary keyword in title and first 120 chars of description
4. **Uniqueness**: Ensure metadata is unique for each blog post
5. **Brand Consistency**: Include brand name where appropriate without exceeding length limits
#### Site Performance
1. **Page Speed**: Optimize page loading speed
2. **Mobile Optimization**: Ensure mobile-friendly design
3. **SSL Certificate**: Use HTTPS for security
4. **Clean URLs**: Use clean, descriptive URLs
5. **Schema Markup**: Implement structured data
#### Social Media Optimization
1. **Image Planning**: Prepare 1200x630px images for Open Graph sharing
2. **Twitter Cards**: Ensure Twitter Card images are 1200x600px minimum
3. **Hashtag Strategy**: Mix industry-specific, trending, and branded hashtags
4. **Platform-Specific**: Review Open Graph vs Twitter Card differences
5. **Testing**: Use Facebook Debugger and Twitter Card Validator before publishing
## Advanced Features
### SEO Workflow Integration
### AI-Powered Optimization
#### Phase 4 to Phase 5 Flow
1. **Score First**: Always complete Phase 4 analysis before metadata generation
2. **Apply Fixes**: Use "Apply Recommendations" to improve scores to 80+
3. **Generate Metadata**: Run Phase 5 with optimized content
4. **Review Metadata**: Verify metadata reflects SEO improvements
5. **Export & Publish**: Copy metadata formats for your platform
#### Content Enhancement
- **Automatic Optimization**: AI-powered content optimization
- **Keyword Suggestions**: Intelligent keyword recommendations
- **Content Improvement**: Automated content improvement suggestions
- **Readability Enhancement**: AI-powered readability improvements
- **Engagement Optimization**: Optimize for user engagement
#### Performance Prediction
- **Ranking Prediction**: Predict potential search rankings
- **Traffic Forecasting**: Forecast organic traffic potential
- **Engagement Prediction**: Predict user engagement levels
- **Conversion Optimization**: Optimize for conversions
- **ROI Analysis**: Analyze return on SEO investment
### Customization Options
#### Analysis Settings
- **Keyword Preferences**: Set keyword analysis preferences
- **Competitor Selection**: Choose competitors for analysis
- **Analysis Depth**: Adjust analysis depth and detail
- **Optimization Goals**: Set specific optimization goals
- **Quality Standards**: Define quality standards and thresholds
#### Reporting Options
- **Custom Reports**: Create custom SEO reports
- **Scheduled Reports**: Set up automated reporting
- **Performance Dashboards**: Create performance dashboards
- **Alert Systems**: Set up performance alerts
- **Export Options**: Export data in various formats
#### Performance Optimization
1. **Cache Utilization**: Leverage research caching from Phase 1 for related topics
2. **Batch Analysis**: Analyze multiple blog drafts in one session to improve learning
3. **Score Tracking**: Monitor SEO score trends across multiple posts
4. **A/B Testing**: Test different metadata variations for CTR optimization
5. **Analytics Integration**: Connect to Google Analytics/Search Console post-publish
## Troubleshooting
### Common Issues
### Common Issues & Solutions
#### SEO Analysis Problems
- **Low SEO Scores**: Address low SEO performance
- **Keyword Issues**: Resolve keyword optimization problems
- **Content Quality**: Improve content quality and structure
- **Technical Issues**: Fix technical SEO problems
- **Performance Issues**: Address performance concerns
#### Low SEO Scores (< 70)
**Problem**: Overall SEO score below 70 or grade showing "Needs Improvement"
#### Optimization Challenges
- **Keyword Overuse**: Avoid keyword stuffing
- **Content Duplication**: Prevent duplicate content issues
- **Technical Errors**: Fix technical SEO errors
- **Performance Problems**: Resolve performance issues
- **Competition Analysis**: Improve competitive analysis
**Solutions**:
- **Check Category Scores**: Review individual category breakdowns to identify weak areas
- **Apply High-Priority Recommendations**: Focus on critical fixes first
- **Verify Content Length**: Ensure 1000+ words for comprehensive analysis
- **Review Heading Structure**: Confirm proper H1/H2/H3 hierarchy
- **Re-run Analysis**: After fixing issues, re-analyze to track improvements
#### Keyword Analysis Issues
**Problem**: Low keyword scores or missing keyword recommendations
**Solutions**:
- **Verify Phase 1 Research**: Ensure Phase 1 keyword analysis completed successfully
- **Check Keyword Density**: Primary keyword should be 1-2% of total content
- **Review Placement**: Ensure keywords appear in title, first paragraph, and subheadings
- **Add Semantic Keywords**: Integrate related terms naturally throughout content
- **Consider Long-Tail**: Include 3-5 long-tail keyword variations
#### "Apply Recommendations" Not Working
**Problem**: Content doesn't update or changes seem minimal
**Solutions**:
- **Check Recommendations**: Verify actionable recommendations are actually present
- **Review Normalization**: Check if AI properly matched section IDs
- **Refresh UI**: Try closing and reopening the SEO Analysis modal
- **Manual Review**: Compare original vs. updated sections for subtle changes
- **Re-Analyze**: Run Phase 4 again to see if scores improved
#### Metadata Generation Issues
**Problem**: Phase 5 generates incomplete or low-quality metadata
**Solutions**:
- **Content Completeness**: Ensure blog content is finalized before metadata generation
- **Title/Slug Issues**: Generate metadata after choosing final blog title
- **Length Constraints**: Verify SEO titles (50-60) and descriptions (150-160) are respected
- **Re-run Phase 5**: If results are suboptimal, regenerate with clearer content
- **Manual Refinement**: Edit generated metadata for brand voice consistency
### Getting Help
#### Support Resources
- **Documentation**: Review SEO analysis documentation
- **Tutorials**: Watch SEO optimization tutorials
- **Best Practices**: Follow SEO best practices
- **Community**: Join user community discussions
- **Support**: Contact technical support
- **[Workflow Guide](workflow-guide.md)**: Complete 6-phase walkthrough
- **[Blog Writer Overview](overview.md)**: Overview of all phases
- **[API Reference](api-reference.md)**: Technical API documentation
- **[Best Practices](../../guides/best-practices.md)**: Advanced optimization tips
#### Optimization Tips
- **Regular Analysis**: Perform regular SEO analysis
- **Continuous Improvement**: Continuously improve SEO performance
- **Performance Monitoring**: Monitor SEO performance metrics
- **Competitive Analysis**: Regular competitive analysis
- **Quality Assurance**: Maintain high quality standards
#### Performance Tips
- **Batch Processing**: Analyze multiple drafts in one session for efficiency
- **Cache Benefits**: Reuse research from Phase 1 to speed up workflow
- **Score Tracking**: Monitor SEO improvements across multiple blog posts
- **Metadata Testing**: Use Facebook Debugger and Twitter Card Validator
- **Analytics Setup**: Connect Google Analytics/Search Console for post-publish tracking
---
*Ready to optimize your content for search engines? [Start with our First Steps Guide](../../getting-started/first-steps.md) and [Explore Blog Writer Features](overview.md) to begin creating SEO-optimized, high-ranking content!*
## Next Steps
Now that you understand Phase 4 & 5, explore the complete workflow:
- **[Phase 1: Research](research.md)** - Comprehensive research capabilities
- **[Complete Workflow Guide](workflow-guide.md)** - End-to-end 6-phase walkthrough
- **[Blog Writer Overview](overview.md)** - All phases overview
- **[Getting Started Guide](../../getting-started/quick-start.md)** - Quick start for new users
---
*Ready to optimize your content for search engines? Check out the [Workflow Guide](workflow-guide.md) to see how Phase 4 & 5 integrate into the complete blog creation process!*

View File

@@ -8,36 +8,36 @@ The ALwrity Blog Writer follows a sophisticated 6-phase workflow designed to cre
```mermaid
flowchart TD
A[Start: Keywords & Topic] --> B[Phase 1: Research & Discovery]
B --> C[Phase 2: Outline Generation]
A[Start: Keywords & Topic] --> B[Phase 1: Research & Strategy]
B --> C[Phase 2: Intelligent Outline]
C --> D[Phase 3: Content Generation]
D --> E[Phase 4: SEO Analysis]
E --> F[Phase 5: Quality Assurance]
F --> G[Phase 6: Publishing]
E --> F[Phase 5: SEO Metadata]
F --> G[Phase 6: Publish & Distribute]
B --> B1[Web Search & Source Collection]
B --> B1[Google Search Grounding]
B --> B2[Competitor Analysis]
B --> B3[Research Caching]
C --> C1[Content Structure Planning]
C --> C2[Section Definition]
C --> C3[Source Mapping]
C --> C1[AI Outline Generation]
C --> C2[Source Mapping]
C --> C3[Title Generation]
D --> D1[Section-by-Section Writing]
D --> D2[Citation Integration]
D --> D3[Continuity Tracking]
D --> D2[Context Memory]
D --> D3[Flow Analysis]
E --> E1[SEO Scoring]
E --> E2[Keyword Analysis]
E --> E3[Readability Assessment]
E --> E2[Actionable Recommendations]
E --> E3[AI-Powered Refinement]
F --> F1[Fact Verification]
F --> F2[Hallucination Detection]
F --> F3[Quality Scoring]
F --> F1[Comprehensive Metadata]
F --> F2[Open Graph & Twitter Cards]
F --> F3[Schema.org Markup]
G --> G1[Platform Integration]
G --> G2[Metadata Generation]
G --> G3[Content Publishing]
G --> G1[Multi-Platform Publishing]
G --> G2[Scheduling]
G --> G3[Version Management]
style A fill:#e3f2fd
style B fill:#e8f5e8
@@ -58,40 +58,40 @@ gantt
dateFormat X
axisFormat %M:%S
section Research
section Phase 1 Research
Keyword Analysis :0, 10
Web Search :10, 30
Source Collection :20, 40
Competitor Analysis :30, 50
Research Caching :40, 60
Google Search :10, 40
Source Extraction :30, 50
Competitor Analysis :40, 60
Research Caching :50, 60
section Outline
Structure Planning :60, 70
Section Definition :70, 80
Source Mapping :80, 90
Title Generation :90, 100
section Phase 2 Outline
AI Structure Planning :60, 80
Section Definition :75, 90
Source Mapping :85, 100
Title Generation :95, 110
section Content
Section 1 Writing :100, 120
Section 2 Writing :120, 140
Section 3 Writing :140, 160
Citation Integration :160, 170
section Phase 3 Content
Section 1 Writing :110, 140
Section 2 Writing :130, 160
Section 3 Writing :150, 180
Context Continuity :170, 200
section SEO
Structure Analysis :170, 180
Keyword Analysis :180, 190
Readability Check :190, 200
SEO Scoring :200, 210
section Phase 4 SEO
Parallel Analysis :200, 215
AI Scoring :210, 230
Recommendations :220, 235
Apply Refinement :230, 250
section Quality
Fact Verification :210, 220
Hallucination Check :220, 230
Quality Scoring :230, 240
section Phase 5 Metadata
Core Metadata :250, 265
Social Tags :260, 275
Schema Markup :270, 285
section Publishing
Platform Integration :240, 250
Metadata Generation :250, 260
Content Publishing :260, 270
section Phase 6 Publish
Platform Setup :285, 295
Content Publishing :290, 310
Verification :305, 320
```
## 📋 Prerequisites
@@ -104,7 +104,7 @@ Before starting, ensure you have:
- **Content Goals**: Defined objectives for your blog post
- **Word Count Target**: Desired length (typically 1000-3000 words)
## 🔍 Phase 1: Research & Discovery
## 🔍 Phase 1: Research & Strategy
### Step 1: Initiate Research
@@ -170,7 +170,7 @@ Before starting, ensure you have:
- ✅ Relevant to your target audience
- ✅ Covers multiple aspects of your topic
## 📝 Phase 2: Outline Generation
## 📝 Phase 2: Intelligent Outline
### Step 1: Generate Outline
@@ -235,6 +235,31 @@ Before starting, ensure you have:
- **Add Sections**: Include missing content areas
- **Improve SEO**: Better keyword distribution
### 🖼️ Generate Images for Sections (Optional)
While in Phase 2, you can generate images for your outline sections.
**How It Works:**
1. Click the **"🖼️ Generate Image"** button on any section in the outline
2. Image modal opens with auto-generated prompt based on section heading
3. Click **"Suggest Prompt"** for AI-optimized suggestions
4. Optionally open **"Advanced Image Options"** for custom settings
5. Choose provider: Stability AI, Hugging Face, or Gemini
6. Generate and images auto-insert into outline and metadata
**Best Practices:**
- Generate images during outline review
- Use specific, descriptive prompts
- Match image style to your brand
- Generate multiple variations if needed
**Image Features:**
- Provider selection (Stability AI, Hugging Face, Gemini)
- Aspect ratio options (1:1, 16:9, 4:3)
- Style customization
- Auto-prompt suggestions
- Platform-optimized outputs
## ✍️ Phase 3: Content Generation
### Step 1: Generate Section Content
@@ -311,7 +336,71 @@ Repeat the process for each outline section:
- Use continuity metrics to ensure flow
- Adjust tone and style as needed
## 🔍 Phase 4: SEO Analysis & Optimization
### Advanced Features in Phase 3
#### ✨ Assistive Writing (Continue Writing)
As you write in any blog section, the AI provides contextual suggestions to help you continue.
**How It Works:**
1. Type 20+ words in any section
2. First suggestion appears automatically below your cursor
3. Click **"Accept"** to insert or **"Dismiss"** to skip
4. Click **"✍️ Continue Writing"** to request more suggestions
5. Suggestions include source citations when available
**Benefits:**
- Real-time writing assistance
- Context-aware continuations
- Source-backed suggestions
- Cost-optimized (first auto, then manual)
#### Quick Edit Options
Select text to access quick edit options in the context menu:
**Available Quick Edits:**
- **✏️ Improve**: Enhance readability and engagement
- ** Add Transition**: Insert transitional phrases (Furthermore, Additionally, Moreover)
- **📏 Shorten**: Condense while maintaining meaning
- **📝 Expand**: Add explanatory content and insights
- **💼 Professionalize**: Make more formal (convert contractions, improve tone)
- **📊 Add Data**: Insert statistical backing statements
**How It Works:**
1. Select any text in your blog content
2. Context menu appears near your cursor
3. Choose a quick edit option
4. Text updates instantly
**Best For:**
- Improving flow between sentences
- Adjusting tone and formality
- Adding supporting statements
- Professionalizing casual language
#### 🔍 Fact-Checking
Verify claims and facts in your content with AI-powered checking.
**How It Works:**
1. Select any paragraph or claim text
2. Right-click or use the context menu
3. Click **"🔍 Fact Check"**
4. Wait 15-30 seconds for analysis
5. Review detailed results with supporting/refuting sources
6. Click **"Apply Fix"** to insert source links if needed
**What Gets Analyzed:**
- Verifiable claims and statements
- Statistical data and percentages
- Dates, names, and events
- Industry-specific facts
**Results Include:**
- Claim-by-claim confidence scores
- Supporting evidence URLs
- Refuting sources (if applicable)
- Overall factual accuracy score
## 🔍 Phase 4: SEO Analysis
### Step 1: Perform SEO Analysis
@@ -356,7 +445,21 @@ Repeat the process for each outline section:
- ✅ Proper heading structure
- ✅ Actionable recommendations
### Step 3: Generate SEO Metadata
### Step 3: Apply SEO Recommendations (Optional)
**Endpoint**: `POST /api/blog/seo/apply-recommendations`
Use the "Apply Recommendations" button to automatically improve your content based on SEO analysis. The AI will:
- Optimize keyword density and placement
- Improve content structure and headings
- Enhance readability and flow
- Maintain your original voice and intent
**Expected Duration**: 20-40 seconds
## 📝 Phase 5: SEO Metadata
### Step 1: Generate Core Metadata
**Endpoint**: `POST /api/blog/seo/metadata`
@@ -373,66 +476,50 @@ Repeat the process for each outline section:
}
```
**Generated Metadata**:
- **SEO Title**: Optimized for search engines
- **Meta Description**: Compelling 155-character description
- **URL Slug**: SEO-friendly URL structure
- **Tags & Categories**: Relevant content classification
- **Social Media Tags**: Open Graph and Twitter Card data
- **JSON-LD Schema**: Structured data for search engines
**What Happens** (First AI Call):
1. **SEO Title**: Optimized for search engines (50-60 chars)
2. **Meta Description**: Compelling description with CTA (150-160 chars)
3. **URL Slug**: Clean, hyphenated, keyword-rich (3-5 words)
4. **Blog Tags**: Mix of primary, semantic, and long-tail keywords (5-8)
5. **Blog Categories**: Industry-standard classification (2-3)
6. **Social Hashtags**: Industry-specific with trending terms (5-10)
7. **Reading Time**: Calculated from word count
## 🛡️ Phase 5: Quality Assurance
**Expected Duration**: 10-15 seconds
### Step 1: Perform Hallucination Check
### Step 2: Generate Social Media & Schema Metadata
**Endpoint**: `POST /api/blog/quality/hallucination-check`
**What Happens** (Second AI Call):
1. **Open Graph Tags**: Optimized for Facebook/LinkedIn sharing
2. **Twitter Cards**: Twitter-specific optimization
3. **JSON-LD Schema**: Structured data for search engines
4. **Multi-Format Export**: WordPress, Wix, HTML, JSON-LD ready formats
**Request Example**:
```json
{
"content": "Complete blog content here...",
"sources": [
"https://example.com/source1",
"https://example.com/source2"
]
}
```
**Generated Metadata Output**:
- **Core Elements**: Title, description, URL slug, tags, categories
- **Social Optimization**: Open Graph and Twitter Card tags
- **Structured Data**: Article schema with author, dates, organization
- **Platform Formats**: Copy-ready for WordPress, Wix, custom
**What Happens**:
1. **Fact Verification**: Checks content against research sources
2. **Hallucination Detection**: Identifies potential AI-generated inaccuracies
3. **Content Validation**: Ensures factual accuracy and credibility
4. **Quality Scoring**: Generates content quality metrics
**Expected Duration**: 10-15 seconds
**Expected Duration**: 15-25 seconds
### Step 2: Review Quality Results
**Key Metrics**:
- **Factual Accuracy**: Percentage of verified claims
- **Source Coverage**: Percentage of content backed by sources
- **Quality Score**: Overall content quality (0-100)
- **Improvement Suggestions**: Specific enhancement recommendations
### Step 3: Review & Export Metadata
**Quality Checklist**:
-High factual accuracy (90%+)
-Good source coverage (80%+)
-Quality score above 85
-No major factual errors
-Clear improvement suggestions
-SEO title is 50-60 characters with primary keyword
-Meta description includes CTA in first 120 chars
-URL slug is clean, readable, and keyword-rich
-Tags and categories are relevant and varied
-Social tags are optimized for each platform
- ✅ Schema markup is valid JSON-LD
### Step 3: Content Optimization (Optional)
**Export Options**:
- Copy HTML meta tags directly to your platform
- Export JSON-LD for search engines
- WordPress-ready format with Yoast compatibility
- Wix integration format
**Endpoint**: `POST /api/blog/section/optimize`
**Common Optimizations**:
- **Improve Readability**: Simplify complex sentences
- **Enhance Engagement**: Add compelling examples and stories
- **Strengthen Arguments**: Provide more supporting evidence
- **Fix Flow Issues**: Improve section transitions
- **Optimize Keywords**: Better keyword integration
## 🚀 Phase 6: Publishing & Distribution
## 🚀 Phase 6: Publish & Distribute
### Step 1: Prepare for Publishing

View File

@@ -0,0 +1,162 @@
# Blog Writer Assets Guide
## 📁 Folder Structure
```
frontend/public/
├── images/
│ └── (add 24 feature images here)
├── videos/
│ └── (add 6 demo videos here)
├── blog-writer-bg.png (already exists ✅)
└── BLOG_WRITER_ASSETS_GUIDE.md (this file)
```
## 🖼️ Required Images (24 total)
### Phase 1: Research & Strategy (4 images)
- `images/research-google-grounding.jpg` - Screenshot/video frame showing Google Search grounding in action
- `images/research-competitor.jpg` - Screenshot of competitor analysis results
- `images/research-keywords.jpg` - Screenshot showing keyword analysis and clustering
- `images/research-angles.jpg` - Screenshot of AI-generated content angle suggestions
### Phase 2: Intelligent Outline (4 images)
- `images/outline-generation.jpg` - Screenshot of AI outline generation interface
- `images/outline-grounding.jpg` - Screenshot showing source mapping and grounding scores
- `images/outline-refine.jpg` - Screenshot of interactive outline refinement (add/remove/merge sections)
- `images/outline-titles.jpg` - Screenshot of multiple AI-generated title options with SEO scores
### Phase 3: Content Generation (4 images)
- `images/content-generation.jpg` - Screenshot of section-by-section content generation
- `images/content-continuity.jpg` - Screenshot showing continuity analysis and flow metrics
- `images/content-sources.jpg` - Screenshot of automatic source integration and citations
- `images/content-medium.jpg` - Screenshot of Medium blog mode quick generation
### Phase 4: SEO Analysis (4 images)
- `images/seo-scoring.jpg` - Screenshot of comprehensive SEO scoring dashboard
- `images/seo-recommendations.jpg` - Screenshot of actionable SEO recommendations list
- `images/seo-apply.jpg` - Screenshot of AI-powered content refinement interface
- `images/seo-keywords.jpg` - Screenshot of keyword density heatmap and analysis
### Phase 5: SEO Metadata (4 images)
- `images/metadata-comprehensive.jpg` - Screenshot of full metadata generation interface
- `images/metadata-social.jpg` - Screenshot of Open Graph and Twitter Cards configuration
- `images/metadata-schema.jpg` - Screenshot of structured data (Schema.org) markup
- `images/metadata-export.jpg` - Screenshot of multi-format output options (HTML, JSON-LD, WordPress, Wix)
### Phase 6: Publish & Distribute (4 images)
- `images/publish-platforms.jpg` - Screenshot of multi-platform publishing options (WordPress, Wix, Medium)
- `images/publish-schedule.jpg` - Screenshot of content scheduling interface with calendar
- `images/publish-versions.jpg` - Screenshot of revision management and version history
- `images/publish-analytics.jpg` - Screenshot of post-publish analytics dashboard
## 🎬 Required Videos (6 total)
### Phase 1: Research & Strategy
- `videos/phase1-research.mp4` - Demo video showing:
- Keyword input and analysis
- Google Search grounding in action
- Competitor analysis results
- Content angle generation
### Phase 2: Intelligent Outline
- `videos/phase2-outline.mp4` - Demo video showing:
- AI outline generation from research
- Source mapping and grounding scores
- Interactive refinement (add/remove sections)
- Title generation with SEO scores
### Phase 3: Content Generation
- `videos/phase3-content.mp4` - Demo video showing:
- Section-by-section content generation
- Continuity analysis and flow metrics
- Source integration and citations
- Medium blog mode
### Phase 4: SEO Analysis
- `videos/phase4-seo.mp4` - Demo video showing:
- SEO scoring dashboard
- Actionable recommendations
- AI-powered content refinement ("Apply Recommendations")
- Keyword analysis
### Phase 5: SEO Metadata
- `videos/phase5-metadata.mp4` - Demo video showing:
- Comprehensive metadata generation
- Open Graph and Twitter Cards
- Structured data (Schema.org)
- Multi-format export options
### Phase 6: Publish & Distribute
- `videos/phase6-publish.mp4` - Demo video showing:
- Multi-platform publishing
- Content scheduling
- Version management
- Analytics integration
## 📝 Image Requirements
- **Format**: JPG/JPEG (recommended for photos) or PNG (recommended for screenshots)
- **Resolution**:
- Minimum: 1200x800px (3:2 aspect ratio for cards)
- Recommended: 1920x1280px for best quality
- **File Size**: Keep under 500KB each for fast loading
- **Content**: Actual screenshots from the working application
## 🎥 Video Requirements
- **Format**: MP4 (H.264 codec recommended)
- **Duration**: 30-90 seconds per phase
- **Resolution**:
- Minimum: 1280x720 (720p)
- Recommended: 1920x1080 (1080p)
- **File Size**: Optimize to keep under 10MB each if possible
- **Content**: Screen recordings showing the actual features in action
## 🚀 How to Add Assets
1. **Create the folders** (already created with .gitkeep files):
```bash
# Folders are already created, just add files
frontend/public/images/
frontend/public/videos/
```
2. **Add your images**:
- Take screenshots or create mockups
- Optimize for web (compress if needed)
- Save with exact filenames listed above
- Place in `frontend/public/images/` folder
3. **Add your videos**:
- Record screen captures of each phase
- Edit to show key features
- Optimize file size
- Save with exact filenames listed above
- Place in `frontend/public/videos/` folder
4. **Test the integration**:
- Run the app: `cd frontend && npm start`
- Open Blog Writer
- Click "🚀 ALwrity Blog Writer SuperPowers"
- Expand each phase to see images and videos
## ✅ Quick Checklist
- [ ] Phase 1: Research images (4) + video (1)
- [ ] Phase 2: Outline images (4) + video (1)
- [ ] Phase 3: Content images (4) + video (1)
- [ ] Phase 4: SEO images (4) + video (1)
- [ ] Phase 5: Metadata images (4) + video (1)
- [ ] Phase 6: Publish images (4) + video (1)
- [ ] Total: 24 images + 6 videos = 30 assets
## 📍 Current Implementation
The images and videos are referenced in:
- `frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx`
- Each phase card shows video when expanded
- Each feature card shows image placeholder
Paths are already configured to use `/images/` and `/videos/` from the public folder.

View File

@@ -0,0 +1,27 @@
# Blog Writer Phase Images
# Add your phase images here:
# - research-google-grounding.jpg
# - research-competitor.jpg
# - research-keywords.jpg
# - research-angles.jpg
# - outline-generation.jpg
# - outline-grounding.jpg
# - outline-refine.jpg
# - outline-titles.jpg
# - content-generation.jpg
# - content-continuity.jpg
# - content-sources.jpg
# - content-medium.jpg
# - seo-scoring.jpg
# - seo-recommendations.jpg
# - seo-apply.jpg
# - seo-keywords.jpg
# - metadata-comprehensive.jpg
# - metadata-social.jpg
# - metadata-schema.jpg
# - metadata-export.jpg
# - publish-platforms.jpg
# - publish-schedule.jpg
# - publish-versions.jpg
# - publish-analytics.jpg

View File

@@ -0,0 +1,9 @@
# Blog Writer Phase Demo Videos
# Add your demo videos here:
# - phase1-research.mp4
# - phase2-outline.mp4
# - phase3-content.mp4
# - phase4-seo.mp4
# - phase5-metadata.mp4
# - phase6-publish.mp4

View File

@@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { debug } from '../../utils/debug';
import { CopilotSidebar } from '@copilotkit/react-ui';
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
import { useCopilotAction } from '@copilotkit/react-core';
import '@copilotkit/react-ui/styles.css';
import { blogWriterApi } from '../../services/blogWriterApi';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../services/blogWriterApi';
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
@@ -26,10 +29,16 @@ import OutlineRefiner from './OutlineRefiner';
import { SEOProcessor } from './SEO';
import BlogWriterLanding from './BlogWriterLanding';
import { OutlineProgressModal } from './OutlineProgressModal';
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
import OutlineFeedbackForm from './OutlineFeedbackForm';
import { BlogEditor } from './WYSIWYG';
import { SEOAnalysisModal } from './SEOAnalysisModal';
import { SEOMetadataModal } from './SEOMetadataModal';
import PhaseNavigation from './PhaseNavigation';
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
import HeaderBar from './BlogWriterUtils/HeaderBar';
import PhaseContent from './BlogWriterUtils/PhaseContent';
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
// Type assertion for CopilotKit action
const useCopilotActionTyped = useCopilotAction as any;
@@ -59,6 +68,7 @@ export const BlogWriter: React.FC = () => {
flowAnalysisResults,
setOutline,
setTitleOptions,
setSelectedTitle,
setSections,
setSeoAnalysis,
setGenMode,
@@ -79,6 +89,227 @@ export const BlogWriter: React.FC = () => {
handleContentSave
} = useBlogWriterState();
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Phase navigation hook
const {
phases,
currentPhase,
navigateToPhase,
resetUserSelection
} = usePhaseNavigation(
research,
outline,
outlineConfirmed,
Object.keys(sections).length > 0,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied
);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = (): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
return "SEO analysis is already open.";
}
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
if (!contentConfirmed) {
setContentConfirmed(true);
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
}
setSeoRecommendationsApplied(false);
if (!isSEOAnalysisModalOpen) {
setIsSEOAnalysisModalOpen(true);
lastSEOModalOpenRef.current = now;
debug.log('[BlogWriter] SEO modal opened (direct)');
}
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
};
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
) => {
if (!outline || outline.length === 0) {
throw new Error('An outline is required before applying recommendations.');
}
const sectionPayload = outline.map((section) => ({
id: section.id,
heading: section.heading,
content: sections[section.id] ?? '',
}));
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
sections: sectionPayload,
outline,
research: (research as any) || {},
recommendations,
});
if (!response.success) {
throw new Error(response.error || 'Failed to apply recommendations.');
}
if (!response.sections || !Array.isArray(response.sections)) {
throw new Error('Recommendation response did not include updated sections.');
}
// Update sections - create new object reference to trigger React re-render
const newSections: Record<string, string> = {};
response.sections.forEach((section) => {
if (section.id && section.content) {
newSections[section.id] = section.content;
}
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
throw new Error('No valid sections received from SEO recommendations application.');
}
// Validate sections have actual content
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
if (sectionsWithContent.length === 0) {
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
}
// Log detailed section info for debugging
const sectionIds = Object.keys(newSections);
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
sectionCount: sectionIds.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
if (response.title && response.title !== selectedTitle) {
setSelectedTitle(response.title);
}
if (response.applied) {
setSeoAnalysis(prev => prev ? { ...prev, applied_recommendations: response.applied } : prev);
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
}
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
setSeoAnalysis(analysis);
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
}, [setSeoAnalysis]);
// Handle SEO modal close - mark SEO phase as done if not already marked
const handleSEOModalClose = useCallback(() => {
// Mark SEO phase as done when modal closes (even without applying recommendations)
if (!seoAnalysis) {
// Set a minimal valid seoAnalysis object to mark phase as complete
setSeoAnalysis({
success: true,
overall_score: 0,
category_scores: {},
analysis_summary: {
overall_grade: 'N/A',
status: 'Skipped',
strongest_category: 'N/A',
weakest_category: 'N/A',
key_strengths: [],
key_weaknesses: [],
ai_summary: 'SEO analysis was skipped by user'
},
actionable_recommendations: [],
generated_at: new Date().toISOString()
});
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
}
setIsSEOAnalysisModalOpen(false);
debug.log('[BlogWriter] SEO modal closed');
}, [seoAnalysis, setSeoAnalysis, setIsSEOAnalysisModalOpen]);
// Mark SEO phase as completed when recommendations are applied
useEffect(() => {
if (seoRecommendationsApplied && seoAnalysis) {
// SEO phase is considered complete when recommendations are applied
// But stay in SEO phase to show updated content
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
// Ensure we stay in SEO phase to show updated content (override auto-progression)
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
}
}
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
// Track when outlines/content become available for the first time
const prevOutlineLenRef = useRef<number>(outline.length);
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
useEffect(() => {
const prevLen = prevOutlineLenRef.current;
if (research && prevLen === 0 && outline.length > 0) {
resetUserSelection();
}
prevOutlineLenRef.current = outline.length;
}, [research, outline.length, resetUserSelection]);
// Only reset user selection when transitioning from not-confirmed to confirmed
useEffect(() => {
const wasConfirmed = prevOutlineConfirmedRef.current;
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
resetUserSelection(); // Allow auto-progression to content phase
}
prevOutlineConfirmedRef.current = outlineConfirmed;
}, [outlineConfirmed, sections, resetUserSelection]);
useEffect(() => {
const wasConfirmed = prevContentConfirmedRef.current;
if (!wasConfirmed && contentConfirmed && seoAnalysis) {
resetUserSelection(); // Allow auto-progression to SEO phase
}
prevContentConfirmedRef.current = contentConfirmed;
}, [contentConfirmed, seoAnalysis, resetUserSelection]);
// Custom hooks for complex functionality
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
outline,
@@ -139,28 +370,63 @@ export const BlogWriter: React.FC = () => {
onError: (err) => console.error('Rewrite failed:', err)
});
// Get context-aware suggestions based on current task status
const suggestions = useSuggestions(
research,
outline,
outlineConfirmed,
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
Object.keys(sections).length > 0, // hasContent
flowAnalysisCompleted, // flowAnalysisCompleted state
contentConfirmed // contentConfirmed state
);
// Add minimum display time for modal
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
const [showOutlineModal, setShowOutlineModal] = useState(false);
// SEO Analysis Modal state
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const suggestions = useSuggestions({
research,
outline,
outlineConfirmed,
researchPolling: { isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
outlinePolling: { isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
mediumPolling: { isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
hasContent: Object.keys(sections).length > 0,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied,
});
// Drive CopilotKit suggestions programmatically
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
const setSuggestionsRef = useRef<any>(null);
useEffect(() => {
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
}, [copilotHeadless]);
const suggestionsPayload = React.useMemo(
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
[suggestions]
);
const prevSuggestionsRef = useRef<string>("__init__");
const suggestionsJson = React.useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
useEffect(() => {
try {
if (!setSuggestionsRef.current) return;
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch {}
}, [suggestionsJson, suggestionsPayload]);
const handlePhaseClick = useCallback((phaseId: string) => {
navigateToPhase(phaseId);
if (phaseId === 'seo') {
if (seoAnalysis) {
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
} else {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect]);
const outlineGenRef = useRef<any>(null);
useEffect(() => {
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
@@ -214,96 +480,73 @@ export const BlogWriter: React.FC = () => {
progressCount: mediumPolling.progressMessages.length
});
// Debug SEO modal state
console.log('🔍 SEO Analysis Modal state:', {
isSEOAnalysisModalOpen,
hasResearch: !!research,
hasContent: !!sections && Object.keys(sections).length > 0,
researchKeys: research ? Object.keys(research) : [],
sectionsKeys: sections ? Object.keys(sections) : []
});
// Log critical state changes only (reduce noise)
const lastPhaseRef = useRef<string>('');
const lastSeoOpenRef = useRef<boolean>(false);
const lastSectionsLenRef = useRef<number>(0);
// Debug action registration
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
// Copilot action for confirming blog content
useCopilotActionTyped({
name: "confirmBlogContent",
description: "Confirm that the blog content is ready and move to the next stage (SEO analysis)",
parameters: [],
handler: async () => {
console.log('Blog content confirmed by user');
setContentConfirmed(true);
return "Blog content has been confirmed! You can now proceed with SEO analysis and publishing.";
useEffect(() => {
if (currentPhase !== lastPhaseRef.current) {
debug.log('[BlogWriter] Phase changed', { currentPhase });
lastPhaseRef.current = currentPhase;
}
});
}, [currentPhase]);
// Copilot action for running SEO analysis
useCopilotActionTyped({
name: "analyzeSEO",
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
parameters: [],
handler: async () => {
console.log('🚀 SEO Analysis Action Triggered!');
console.log('Current modal state before:', isSEOAnalysisModalOpen);
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
console.log('Research data available:', !!research && !!research.keyword_analysis);
// Check if we have content to analyze
if (!sections || Object.keys(sections).length === 0) {
console.log('❌ No content available for SEO analysis');
return "No blog content available for SEO analysis. Please generate content first.";
useEffect(() => {
const open = isSEOAnalysisModalOpen;
if (open !== lastSeoOpenRef.current) {
debug.log('[BlogWriter] SEO modal', { isOpen: open });
lastSeoOpenRef.current = open;
}
}, [isSEOAnalysisModalOpen]);
useEffect(() => {
const len = Object.keys(sections || {}).length;
if (len !== lastSectionsLenRef.current) {
debug.log('[BlogWriter] Sections updated', { count: len });
lastSectionsLenRef.current = len;
}
}, [sections]);
useEffect(() => {
debug.log('[BlogWriter] Suggestions updated', { suggestions });
}, [suggestions]);
// Force-sync Copilot suggestions right after SEO recommendations applied (guarded by previous suggestions key)
useEffect(() => {
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
try {
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
// Check if we have research data
if (!research || !research.keyword_analysis) {
console.log('❌ No research data available for SEO analysis');
return "Research data is required for SEO analysis. Please run research first.";
}
// Open SEO analysis modal
console.log('✅ All checks passed, opening SEO analysis modal');
} catch (e) {
console.error('Failed to push Copilot suggestions after SEO apply:', e);
}
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
const confirmBlogContentCb = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');
setContentConfirmed(true);
resetUserSelection();
setSeoRecommendationsApplied(false);
navigateToPhase('seo');
setTimeout(() => {
setIsSEOAnalysisModalOpen(true);
console.log('Modal state set to true');
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
}
});
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
}, 0);
return "✅ Blog content has been confirmed! Running SEO analysis now.";
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
// Generate SEO Metadata Action
useCopilotActionTyped({
name: "generateSEOMetadata",
description: "Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data",
parameters: [
{
name: "title",
type: "string",
description: "Optional blog title to use for metadata generation",
required: false
}
],
handler: async ({ title }: { title?: string }) => {
console.log('🚀 Generate SEO Metadata Action Triggered!');
console.log('Title provided:', title);
console.log('Selected title:', selectedTitle);
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
console.log('Research data available:', !!research && !!research.keyword_analysis);
// Check if we have content to generate metadata for
if (!sections || Object.keys(sections).length === 0) {
return "Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.";
}
if (!research || !research.keyword_analysis) {
return "Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.";
}
// Open the SEO metadata modal
setIsSEOMetadataModalOpen(true);
console.log('SEO Metadata modal opened');
return "Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.";
}
useBlogWriterCopilotActions({
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent: confirmBlogContentCb,
sections,
research,
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
});
@@ -366,6 +609,7 @@ export const BlogWriter: React.FC = () => {
{/* New extracted functionality components */}
<OutlineGenerator
ref={outlineGenRef}
research={research}
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
@@ -395,241 +639,70 @@ export const BlogWriter: React.FC = () => {
{!research ? (
<BlogWriterLanding
onStartWriting={() => {
// This will trigger the copilot to start the research process
// The user can then interact with the copilot to begin research
// Trigger the copilot to start the research process
}}
/>
) : (
<>
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{research && outline.length === 0 && <ResearchResults research={research} />}
{outline.length > 0 && (
<div>
{outlineConfirmed ? (
/* WYSIWYG Editor - Show when outline is confirmed */
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
/* Outline Editor - Show when outline is not confirmed */
<>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
</>
)}
</div>
)}
</div>
</div>
<HeaderBar
phases={phases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
/>
<PhaseContent
currentPhase={currentPhase}
research={research}
outline={outline}
outlineConfirmed={outlineConfirmed}
titleOptions={titleOptions}
selectedTitle={selectedTitle}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
setOutline={setOutline}
sections={sections}
handleContentUpdate={handleContentUpdate}
handleContentSave={handleContentSave}
continuityRefresh={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
outlineGenRef={outlineGenRef}
blogWriterApi={blogWriterApi}
contentConfirmed={contentConfirmed}
seoAnalysis={seoAnalysis}
seoMetadata={seoMetadata}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
</>
)}
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.'
}}
<WriterCopilotSidebar
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
// Get current state information
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch ? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
angles: research.suggested_angles?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational'
} : null;
const outlineContext = hasOutline ? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map(s => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline.map(s => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`).join('; ')}
` : '';
const toolGuide = `
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
CURRENT STATE:
${hasResearch && researchInfo ? `
✅ RESEARCH COMPLETED:
- Found ${researchInfo.sources} sources with Google Search grounding
- Generated ${researchInfo.queries} search queries
- Created ${researchInfo.angles} content angles
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:
- getResearchKeywords(prompt?: string) - Get keywords from user for research
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
- researchTopic(keywords: string, industry?: string, target_audience?: string)
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
- generateOutline()
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
- refineOutline(prompt?: string) - Refine outline based on user feedback
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
- generateSection(sectionId: string)
- generateAllSections()
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
- generateSEOMetadata(title?: string)
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
CRITICAL BEHAVIOR & USER GUIDANCE:
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
- When user asks to research something, call getResearchKeywords() first to collect their keywords
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
USER GUIDANCE STRATEGY:
- After research completion, ALWAYS guide user toward outline creation as the next step
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
- After outline generation, ALWAYS guide user to review and confirm the outline
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
- After content generation, guide users to review and confirm their content before moving to SEO stage
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after content confirmation, show SEO analysis and publishing suggestions
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
ENGAGEMENT TACTICS:
- DO NOT ask for clarification - take action immediately with the information provided
- Always call the appropriate tool instead of just talking about what you could do
- Be aware of the current state and reference research results when relevant
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
- Use encouraging language and highlight progress made
- If user seems lost, remind them of the current stage and suggest the next step
- When research is complete, emphasize the value of the data found and guide to outline creation
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
- Encourage users to make small manual edits to the outline UI before using AI for major changes
`;
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
research={research}
outline={outline}
outlineConfirmed={outlineConfirmed}
/>
{/* Outline Progress Modal */}
{/* Outline modal */}
<OutlineProgressModal
isVisible={showOutlineModal}
status={outlinePolling.currentStatus}
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
error={outlinePolling.error}
/>
{/* Medium generation / Rewrite modal */}
<OutlineProgressModal
isVisible={showModal}
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
latestMessage={rewritePolling.isPolling ?
(rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : '') :
(mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : '')
}
error={rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error}
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
<TaskProgressModals
showOutlineModal={showOutlineModal}
outlinePolling={outlinePolling}
showModal={showModal}
rewritePolling={rewritePolling}
mediumPolling={mediumPolling}
/>
{/* SEO Analysis Modal */}
<SEOAnalysisModal
isOpen={isSEOAnalysisModalOpen}
onClose={() => setIsSEOAnalysisModalOpen(false)}
onClose={handleSEOModalClose}
blogContent={buildFullMarkdown()}
blogTitle={selectedTitle}
researchData={research}
onApplyRecommendations={(recommendations) => {
console.log('Applying SEO recommendations:', recommendations);
// TODO: Implement recommendation application logic
}}
onApplyRecommendations={handleApplySeoRecommendations}
onAnalysisComplete={handleSEOAnalysisComplete}
/>
{/* SEO Metadata Modal */}
@@ -639,10 +712,14 @@ Available tools:
blogContent={buildFullMarkdown()}
blogTitle={selectedTitle}
researchData={research}
outline={outline}
seoAnalysis={seoAnalysis}
onMetadataGenerated={(metadata) => {
console.log('SEO metadata generated:', metadata);
setSeoMetadata(metadata);
// TODO: Implement metadata application logic
// Metadata is now saved and will be used when publishing to WordPress/Wix
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
// Publisher component will use this metadata when calling publish API
}}
/>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
interface BlogWriterLandingProps {
onStartWriting: () => void;
@@ -198,7 +199,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
</div>
</div>
{/* SuperPowers Modal */}
{/* SuperPowers Modal with 6 Phases */}
{showSuperPowers && (
<div style={{
position: 'fixed',
@@ -206,20 +207,18 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
backgroundColor: 'rgba(0, 0, 0, 0.95)',
display: 'flex',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 1000
zIndex: 1000,
overflowY: 'auto'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '20px',
padding: '40px',
maxWidth: '900px',
width: '90%',
maxHeight: '80vh',
overflow: 'auto',
width: '100%',
maxWidth: '1400px',
minHeight: '100%',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
}}>
{/* Modal Header */}
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
</button>
</div>
{/* SuperPowers Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '24px'
}}>
{superPowers.map((power, index) => (
<div
key={index}
style={{
padding: '24px',
borderRadius: '16px',
border: '1px solid #e0e0e0',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
e.currentTarget.style.borderColor = '#1976d2';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '12px'
}}>
{/* 6 Phases Section */}
<BlogWriterPhasesSection />
{/* Quick SuperPowers Grid */}
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
<h2 style={{
margin: '0 0 20px 0',
fontSize: '1.5rem',
textAlign: 'center',
color: '#333'
}}>
Quick Feature Overview
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px'
}}>
{superPowers.map((power, index) => (
<div
key={index}
style={{
padding: '20px',
borderRadius: '12px',
border: '1px solid #e0e0e0',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
e.currentTarget.style.borderColor = '#1976d2';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
<div style={{
fontSize: '2rem',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
borderRadius: '12px'
gap: '16px',
marginBottom: '12px'
}}>
{power.icon}
<div style={{
fontSize: '2rem',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
borderRadius: '12px'
}}>
{power.icon}
</div>
<h3 style={{
margin: 0,
fontSize: '1.1rem',
color: '#333',
fontWeight: '600'
}}>
{power.title}
</h3>
</div>
<h3 style={{
<p style={{
margin: 0,
fontSize: '1.3rem',
color: '#333',
fontWeight: '600'
color: '#666',
lineHeight: '1.6',
fontSize: '0.9rem'
}}>
{power.title}
</h3>
{power.description}
</p>
</div>
<p style={{
margin: 0,
color: '#666',
lineHeight: '1.6',
fontSize: '1rem'
}}>
{power.description}
</p>
</div>
))}
))}
</div>
</div>
{/* Modal Footer */}

View File

@@ -0,0 +1,576 @@
import React, { useState } from 'react';
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
interface PhaseFeature {
title: string;
description: string;
details: string[];
imagePlaceholder: string;
}
interface BlogPhase {
id: string;
name: string;
icon: string;
shortDescription: string;
features: PhaseFeature[];
technicalDetails: {
aiModel: string;
promptType: string;
outputFormat: string;
integration: string;
};
videoPlaceholder: string;
}
const BlogWriterPhasesSection: React.FC = () => {
const [activePhase, setActivePhase] = useState<number | null>(null);
const phases: BlogPhase[] = [
{
id: 'research',
name: 'Research & Strategy',
icon: '🔍',
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
features: [
{
title: 'Google Search Grounding',
description: 'Real-time web research using Gemini\'s native Google Search integration',
details: [
'Single API call for comprehensive research',
'Live web data from credible sources',
'Automatic source extraction and citation',
'Current trends and 2024-2025 insights',
'Market analysis and forecasts'
],
imagePlaceholder: '/images/research-google-grounding.jpg'
},
{
title: 'Competitor Analysis',
description: 'Identify top players and content opportunities in your niche',
details: [
'Top competitor content analysis',
'Content gap identification',
'Unique angle discovery',
'Market positioning insights',
'Competitive advantage opportunities'
],
imagePlaceholder: '/images/research-competitor.jpg'
},
{
title: 'Keyword Intelligence',
description: 'Comprehensive keyword analysis with SEO opportunities',
details: [
'Primary, secondary, and long-tail keyword identification',
'Search volume and competition analysis',
'Keyword clustering and grouping',
'Content optimization suggestions',
'Target audience keyword mapping'
],
imagePlaceholder: '/images/research-keywords.jpg'
},
{
title: 'Content Angle Generation',
description: 'AI-generated compelling content angles for maximum engagement',
details: [
'5 unique content angle suggestions',
'Trending topic identification',
'Audience pain point mapping',
'Viral potential assessment',
'Expert opinion synthesis'
],
imagePlaceholder: '/images/research-angles.jpg'
}
],
technicalDetails: {
aiModel: 'Gemini Pro with Google Search Grounding',
promptType: 'Comprehensive research prompt',
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
integration: 'GeminiGroundedProvider via research_service.py'
},
videoPlaceholder: '/videos/phase1-research.mp4'
},
{
id: 'outline',
name: 'Intelligent Outline',
icon: '📝',
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
features: [
{
title: 'AI Outline Generation',
description: 'Comprehensive outline based on research with SEO optimization',
details: [
'Section-by-section breakdown',
'Subheadings and key points',
'Target word counts per section',
'Logical flow and progression',
'SEO-optimized structure'
],
imagePlaceholder: '/images/outline-generation.jpg'
},
{
title: 'Source Mapping & Grounding',
description: 'Connect each section to research sources with citations',
details: [
'Automatic source-to-section mapping',
'Grounding support scores',
'Citation suggestions',
'Source credibility ratings',
'Reference verification'
],
imagePlaceholder: '/images/outline-grounding.jpg'
},
{
title: 'Interactive Refinement',
description: 'Human-in-the-loop editing with AI assistance',
details: [
'Add, remove, merge sections',
'Reorder and restructure',
'AI enhancement suggestions',
'Custom instructions support',
'Multiple outline versions'
],
imagePlaceholder: '/images/outline-refine.jpg'
},
{
title: 'Title Generation',
description: 'Multiple SEO-optimized title options',
details: [
'AI-generated title variations',
'SEO score per title',
'Engagement potential analysis',
'Keyword integration',
'Click-through optimization'
],
imagePlaceholder: '/images/outline-titles.jpg'
}
],
technicalDetails: {
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
promptType: 'Structured outline prompt with research context',
outputFormat: 'JSON outline with sections, headings, key_points, references',
integration: 'OutlineService via parallel_processor.py'
},
videoPlaceholder: '/videos/phase2-outline.mp4'
},
{
id: 'content',
name: 'Content Generation',
icon: '✨',
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
features: [
{
title: 'Smart Content Generation',
description: 'AI-powered section writing with context awareness',
details: [
'Section-by-section generation',
'Context memory across sections',
'Smooth transitions between sections',
'Consistent tone and style',
'Natural keyword integration'
],
imagePlaceholder: '/images/content-generation.jpg'
},
{
title: 'Continuity Analysis',
description: 'Real-time flow and coherence monitoring',
details: [
'Narrative flow assessment',
'Coherence scoring',
'Transition quality analysis',
'Tone consistency tracking',
'Content quality metrics'
],
imagePlaceholder: '/images/content-continuity.jpg'
},
{
title: 'Source Integration',
description: 'Automatic citation and source reference',
details: [
'Relevant URL selection',
'Natural citation insertion',
'Source attribution',
'Evidence-backed content',
'Reference management'
],
imagePlaceholder: '/images/content-sources.jpg'
},
{
title: 'Medium Blog Mode',
description: 'Quick generation for Medium-style articles',
details: [
'Single-call full blog generation',
'Medium-optimized formatting',
'Engagement-focused structure',
'SEO-ready output',
'Fast turnaround option'
],
imagePlaceholder: '/images/content-medium.jpg'
}
],
technicalDetails: {
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
promptType: 'Context-aware section prompt with research',
outputFormat: 'Markdown content with transitions and metrics',
integration: 'EnhancedContentGenerator with ContextMemory'
},
videoPlaceholder: '/videos/phase3-content.mp4'
},
{
id: 'seo',
name: 'SEO Analysis',
icon: '📈',
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
features: [
{
title: 'Comprehensive SEO Scoring',
description: 'Multi-dimensional SEO analysis across key factors',
details: [
'Overall SEO score (0-100)',
'Structure optimization score',
'Keyword optimization rating',
'Readability assessment',
'Quality metrics evaluation'
],
imagePlaceholder: '/images/seo-scoring.jpg'
},
{
title: 'Actionable Recommendations',
description: 'AI-powered improvement suggestions',
details: [
'Priority-ranked fixes',
'Specific text improvements',
'Keyword density optimization',
'Heading structure suggestions',
'Content enhancement ideas'
],
imagePlaceholder: '/images/seo-recommendations.jpg'
},
{
title: 'AI-Powered Content Refinement',
description: 'Automatically apply SEO recommendations',
details: [
'Smart content rewriting',
'Preserves original intent',
'Natural keyword integration',
'Readability improvement',
'Structure optimization'
],
imagePlaceholder: '/images/seo-apply.jpg'
},
{
title: 'Keyword Analysis',
description: 'Deep dive into keyword performance',
details: [
'Primary keyword density',
'Semantic keyword usage',
'Long-tail keyword opportunities',
'Keyword distribution heatmap',
'Optimization recommendations'
],
imagePlaceholder: '/images/seo-keywords.jpg'
}
],
technicalDetails: {
aiModel: 'Parallel non-AI analyzers + single AI call',
promptType: 'Structured SEO analysis prompt',
outputFormat: 'Comprehensive SEO report with scores and recommendations',
integration: 'BlogContentSEOAnalyzer with parallel processing'
},
videoPlaceholder: '/videos/phase4-seo.mp4'
},
{
id: 'metadata',
name: 'SEO Metadata',
icon: '🎯',
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
features: [
{
title: 'Comprehensive Metadata',
description: 'All-in-one SEO metadata generation',
details: [
'SEO-optimized title (50-60 chars)',
'Meta description with CTA',
'URL slug optimization',
'Blog tags and categories',
'Social hashtags'
],
imagePlaceholder: '/images/metadata-comprehensive.jpg'
},
{
title: 'Open Graph & Twitter Cards',
description: 'Rich social media previews',
details: [
'OG title and description',
'Twitter card optimization',
'Image preview settings',
'Social engagement boost',
'Click-through optimization'
],
imagePlaceholder: '/images/metadata-social.jpg'
},
{
title: 'Structured Data',
description: 'Schema.org markup for rich snippets',
details: [
'Article schema',
'Organization markup',
'Breadcrumb schema',
'FAQ schema support',
'Enhanced search results'
],
imagePlaceholder: '/images/metadata-schema.jpg'
},
{
title: 'Multi-Format Output',
description: 'Ready-to-use metadata in all formats',
details: [
'HTML meta tags',
'JSON-LD structured data',
'WordPress export format',
'Wix integration format',
'One-click copy options'
],
imagePlaceholder: '/images/metadata-export.jpg'
}
],
technicalDetails: {
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
promptType: 'Personalized metadata prompt with context',
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
integration: 'BlogSEOMetadataGenerator with optimization'
},
videoPlaceholder: '/videos/phase5-metadata.mp4'
},
{
id: 'publish',
name: 'Publish & Distribute',
icon: '🚀',
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
features: [
{
title: 'Multi-Platform Publishing',
description: 'Publish to multiple platforms simultaneously',
details: [
'WordPress direct publishing',
'Wix blog integration',
'Medium publishing',
'Custom blog platforms',
'API integrations'
],
imagePlaceholder: '/images/publish-platforms.jpg'
},
{
title: 'Content Scheduling',
description: 'Schedule posts for optimal timing',
details: [
'Time-based scheduling',
'Timezone management',
'Bulk scheduling support',
'Calendar integration',
'Reminder notifications'
],
imagePlaceholder: '/images/publish-schedule.jpg'
},
{
title: 'Revision Management',
description: 'Track and manage content versions',
details: [
'Version history',
'Change tracking',
'Rollback capabilities',
'A/B testing support',
'Performance comparison'
],
imagePlaceholder: '/images/publish-versions.jpg'
},
{
title: 'Analytics Integration',
description: 'Post-publish performance tracking',
details: [
'View count tracking',
'Engagement metrics',
'SEO performance',
'Traffic analysis',
'Conversion tracking'
],
imagePlaceholder: '/images/publish-analytics.jpg'
}
],
technicalDetails: {
aiModel: 'Platform-specific API integrations',
promptType: 'N/A - publishing only',
outputFormat: 'Published content with URL',
integration: 'Platform APIs via Publisher component'
},
videoPlaceholder: '/videos/phase6-publish.mp4'
}
];
return (
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
<Container maxWidth="lg">
{/* Section Title */}
<Box sx={{ textAlign: 'center', mb: 6 }}>
<Typography
variant="h2"
component="h2"
sx={{
fontSize: { xs: '2rem', md: '3rem' },
fontWeight: 700,
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 2
}}
>
Complete AI Blog Writing Workflow
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
</Typography>
</Box>
{/* Phase Cards */}
<Grid container spacing={4}>
{phases.map((phase, index) => (
<Grid item xs={12} md={6} key={phase.id}>
<Card
sx={{
height: '100%',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: activePhase === index ? 2 : 1,
borderColor: activePhase === index ? 'primary.main' : 'divider',
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: 6,
}
}}
onClick={() => setActivePhase(activePhase === index ? null : index)}
>
<CardContent sx={{ p: 3 }}>
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
{phase.icon}
</Typography>
<Box flex={1}>
<Typography variant="h5" fontWeight={600} gutterBottom>
{phase.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{phase.shortDescription}
</Typography>
</Box>
<Chip
label={`Phase ${index + 1}`}
size="small"
color="primary"
variant="outlined"
/>
</Stack>
{activePhase === index && (
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
{/* Video Placeholder */}
<Box
sx={{
width: '100%',
aspectRatio: '16/9',
bgcolor: 'grey.200',
borderRadius: 2,
mb: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="body2" color="text.secondary">
🎥 Video: {phase.videoPlaceholder}
</Typography>
</Box>
{/* Features Grid */}
<Grid container spacing={2} mb={3}>
{phase.features.map((feature, idx) => (
<Grid item xs={12} sm={6} key={idx}>
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
<Box
sx={{
width: '100%',
aspectRatio: '4/3',
bgcolor: 'grey.100',
borderRadius: 1,
mb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="caption" color="text.secondary">
📷 Image
</Typography>
</Box>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
{feature.title}
</Typography>
<Typography variant="body2" color="text.secondary" mb={1}>
{feature.description}
</Typography>
<Stack spacing={0.5}>
{feature.details.slice(0, 3).map((detail, i) => (
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
<Typography variant="caption" color="text.secondary">
{detail}
</Typography>
</Stack>
))}
</Stack>
</Card>
</Grid>
))}
</Grid>
{/* Technical Details */}
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesome sx={{ fontSize: 18 }} />
Technical Implementation
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Integration</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{phase.technicalDetails.integration}
</Typography>
</Grid>
</Grid>
</Card>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Container>
</Box>
);
};
export default BlogWriterPhasesSection;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PhaseNavigation from '../PhaseNavigation';
interface HeaderBarProps {
phases: any[];
currentPhase: string;
onPhaseClick: (phaseId: string) => void;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPhaseClick }) => {
return (
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: 'bold',
color: '#666'
}}>
A
</div>
</div>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
/>
</div>
);
};
export default HeaderBar;

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface OutlineCtaBannerProps {
onGenerate: () => void;
}
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
return (
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
<button
onClick={onGenerate}
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
>
Next: Create Outline
</button>
</div>
);
};
export default OutlineCtaBanner;

View File

@@ -0,0 +1,185 @@
import React from 'react';
import ResearchResults from '../ResearchResults';
import EnhancedTitleSelector from '../EnhancedTitleSelector';
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
import { BlogEditor } from '../WYSIWYG';
import OutlineCtaBanner from './OutlineCtaBanner';
interface PhaseContentProps {
currentPhase: string;
research: any;
outline: any[];
outlineConfirmed: boolean;
titleOptions: any[];
selectedTitle?: string | null;
researchTitles: any[];
aiGeneratedTitles: any[];
sourceMappingStats: any;
groundingInsights: any;
optimizationResults: any;
researchCoverage: any;
setOutline: (o: any) => void;
sections: Record<string, string>;
handleContentUpdate: any;
handleContentSave: any;
continuityRefresh: number | null;
flowAnalysisResults: any;
outlineGenRef: React.RefObject<any>;
blogWriterApi: any;
contentConfirmed: boolean;
seoAnalysis: any;
seoMetadata: any;
onTitleSelect: any;
onCustomTitle: any;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
currentPhase,
research,
outline,
outlineConfirmed,
titleOptions,
selectedTitle,
researchTitles,
aiGeneratedTitles,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
setOutline,
sections,
handleContentUpdate,
handleContentSave,
continuityRefresh,
flowAnalysisResults,
outlineGenRef,
blogWriterApi,
contentConfirmed,
seoAnalysis,
seoMetadata,
onTitleSelect,
onCustomTitle
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{currentPhase === 'research' && (
<>
{research ? (
<ResearchResults research={research} />
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Start Your Research</h3>
<p>Use the copilot to begin researching your blog topic.</p>
</div>
)}
</>
)}
{currentPhase === 'outline' && research && (
<>
{outline.length === 0 && (
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
)}
{outline.length > 0 ? (
<>
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle || undefined}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={onTitleSelect}
onCustomTitle={onCustomTitle}
/>
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
</>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Create Your Outline</h3>
<p>Use the copilot to generate an outline based on your research.</p>
</div>
)}
</>
)}
{currentPhase === 'content' && outline.length > 0 && (
<>
{outlineConfirmed ? (
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Confirm Your Outline</h3>
<p>Review and confirm your outline before generating content.</p>
</div>
)}
</>
)}
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
<>
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Loading Content...</h3>
<p>Please wait while your content is being optimized.</p>
</div>
)}
</>
)}
{/* Fallback for SEO phase if conditions not met */}
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Optimize your blog for search engines.</h3>
<p>Complete the content phase first to enable SEO optimization.</p>
</div>
)}
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
<div style={{ padding: '20px' }}>
<h3>Publish Your Blog</h3>
<p>Your blog is ready to publish!</p>
</div>
)}
</div>
</div>
);
};
export default PhaseContent;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { OutlineProgressModal } from '../OutlineProgressModal';
interface PollingState {
isPolling: boolean;
currentStatus: string;
progressMessages: { message: string }[];
error?: string | null;
}
interface TaskProgressModalsProps {
showOutlineModal: boolean;
outlinePolling: PollingState;
showModal: boolean;
rewritePolling: PollingState;
mediumPolling: PollingState;
}
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
showOutlineModal,
outlinePolling,
showModal,
rewritePolling,
mediumPolling,
}) => {
return (
<>
<OutlineProgressModal
isVisible={showOutlineModal}
status={outlinePolling.currentStatus}
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
error={outlinePolling.error ?? null}
/>
<OutlineProgressModal
isVisible={showModal}
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
latestMessage={rewritePolling.isPolling ? (
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
) : (
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
)}
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
/>
</>
);
};
export default TaskProgressModals;

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { CopilotSidebar } from '@copilotkit/react-ui';
import '@copilotkit/react-ui/styles.css';
interface WriterCopilotSidebarProps {
suggestions: any[];
research: any;
outline: any[];
outlineConfirmed: boolean;
}
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
suggestions,
research,
outline,
outlineConfirmed,
}) => {
return (
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
}}
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch
? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
angles: research.suggested_angles?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational',
}
: null;
const outlineContext = hasOutline
? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map((s: any) => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum: number, s: any) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline
.map(
(s: any) => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`
)
.join('; ')}
`
: '';
const toolGuide = `
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
CURRENT STATE:
${hasResearch && researchInfo ? `
✅ RESEARCH COMPLETED:
- Found ${researchInfo.sources} sources with Google Search grounding
- Generated ${researchInfo.queries} search queries
- Created ${researchInfo.angles} content angles
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:
- getResearchKeywords(prompt?: string) - Get keywords from user for research
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
- researchTopic(keywords: string, industry?: string, target_audience?: string)
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
- generateOutline()
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
- refineOutline(prompt?: string) - Refine outline based on user feedback
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
- generateSection(sectionId: string)
- generateAllSections()
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
- generateSEOMetadata(title?: string)
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
CRITICAL BEHAVIOR & USER GUIDANCE:
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
- When user asks to research something, call getResearchKeywords() first to collect their keywords
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
USER GUIDANCE STRATEGY:
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
- After research completion, ALWAYS guide user toward outline creation as the next step
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
- After outline generation, ALWAYS guide user to review and confirm the outline
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
- After content generation, guide users to review and confirm their content before moving to SEO stage
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after content confirmation, show SEO analysis and publishing suggestions
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
ENGAGEMENT TACTICS:
- DO NOT ask for clarification - take action immediately with the information provided
- Always call the appropriate tool instead of just talking about what you could do
- Be aware of the current state and reference research results when relevant
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
- Use encouraging language and highlight progress made
- If user seems lost, remind them of the current stage and suggest the next step
- When research is complete, emphasize the value of the data found and guide to outline creation
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
- Encourage users to make small manual edits to the outline UI before using AI for major changes
`;
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
/>
);
};
export default WriterCopilotSidebar;

View File

@@ -0,0 +1,90 @@
import { useRef } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { debug } from '../../../utils/debug';
type ConfirmCb = () => string | Promise<string>;
type AnalyzeCb = () => string | Promise<string>;
type OpenMetadataCb = () => void;
interface UseBlogWriterCopilotActionsParams {
isSEOAnalysisModalOpen: boolean;
lastSEOModalOpenRef: React.MutableRefObject<number>;
runSEOAnalysisDirect: AnalyzeCb;
confirmBlogContent: ConfirmCb;
sections: Record<string, string>;
research: any;
openSEOMetadata: OpenMetadataCb;
}
// Consolidates all Copilot actions used by BlogWriter
export function useBlogWriterCopilotActions({
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent,
sections,
research,
openSEOMetadata,
}: UseBlogWriterCopilotActionsParams) {
// Maintain the same any-cast pattern for parity with component
const useCopilotActionTyped = useCopilotAction as any;
// confirmBlogContent
useCopilotActionTyped({
name: 'confirmBlogContent',
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
parameters: [],
handler: async () => {
const msg = await confirmBlogContent();
return msg;
},
});
// analyzeSEO
useCopilotActionTyped({
name: 'analyzeSEO',
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
parameters: [],
handler: async () => {
debug.log('[BlogWriter] SEO analysis action', {
modalOpen: isSEOAnalysisModalOpen,
hasSections: !!sections && Object.keys(sections).length > 0,
hasResearch: !!research && !!(research as any)?.keyword_analysis,
});
const now = Date.now();
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
return 'SEO analysis is already open.';
}
const msg = await runSEOAnalysisDirect();
return msg;
},
});
// generateSEOMetadata
useCopilotActionTyped({
name: 'generateSEOMetadata',
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
parameters: [
{
name: 'title',
type: 'string',
description: 'Optional blog title to use for metadata generation',
required: false,
},
],
handler: async ({ title }: { title?: string }) => {
if (!sections || Object.keys(sections).length === 0) {
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
}
if (!research || !research.keyword_analysis) {
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
}
openSEOMetadata();
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
},
});
}
export default useBlogWriterCopilotActions;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { blogWriterApi } from '../../services/blogWriterApi';
import { debug } from '../../utils/debug';
interface Props {
sectionId: string;
@@ -17,36 +18,27 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disa
// If we have flow analysis results, use them instead of API call
if (flowAnalysisResults && flowAnalysisResults.sections) {
console.log('🔍 [ContinuityBadge] Flow analysis results available:', flowAnalysisResults);
console.log('🔍 [ContinuityBadge] Looking for section ID:', sectionId);
console.log('🔍 [ContinuityBadge] Available section IDs:', flowAnalysisResults.sections.map((s: any) => s.section_id));
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
if (sectionAnalysis) {
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
if (mounted) {
setMetrics({
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
flow: sectionAnalysis.flow_score,
consistency: sectionAnalysis.consistency_score,
progression: sectionAnalysis.progression_score
});
}
return;
} else {
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
}
}
// Fallback to API call if no flow analysis results
console.log('🔍 [ContinuityBadge] Fetching continuity for section:', sectionId);
debug.log('[ContinuityBadge] fetching', { sectionId });
blogWriterApi.getContinuity(sectionId)
.then(res => {
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
if (mounted) setMetrics(res.continuity_metrics || null);
})
.catch((error) => {
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
/* ignore */
debug.error('[ContinuityBadge] fetch error', error);
});
return () => { mounted = false; };
}, [sectionId, refreshToken, flowAnalysisResults]);

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
interface Props {
outline: BlogOutlineSection[];
@@ -24,7 +25,10 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
}) => {
const [editingSection, setEditingSection] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
const [showAddSection, setShowAddSection] = useState(false);
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
const [newSectionData, setNewSectionData] = useState({
heading: '',
subheadings: '',
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
border: '1px solid #e0e0e0',
overflow: 'hidden'
}}>
{imageModalState.open && (
<ImageGeneratorModal
isOpen={imageModalState.open}
onClose={() => setImageModalState({ open: false })}
defaultPrompt={(() => {
const sec = outline.find(s => s.id === imageModalState.sectionId);
return sec?.heading || '';
})()}
context={(() => {
const sec = outline.find(s => s.id === imageModalState.sectionId);
return {
title: sec?.heading,
section: sec,
outline,
research,
sectionId: imageModalState.sectionId
};
})()}
onImageGenerated={(imageBase64, sectionId) => {
if (sectionId) {
setSectionImages(prev => ({ ...prev, [sectionId]: imageBase64 }));
}
}}
/>
)}
{/* Header */}
<div style={{
padding: '20px',
@@ -275,12 +304,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
{/* Section Header */}
<div style={{
padding: '16px 20px',
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
justifyContent: 'space-between',
transition: 'background-color 0.2s ease'
}}
onMouseEnter={() => setHoveredSection(section.id)}
onMouseLeave={() => setHoveredSection(null)}
onClick={() => toggleExpanded(section.id)}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<div style={{
@@ -375,6 +407,24 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setImageModalState({ open: true, sectionId: section.id });
}}
title="Generate Image"
style={{
backgroundColor: '#1976d2',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
fontSize: '12px',
color: '#fff'
}}
>
🖼 Generate Image
</button>
<button
onClick={(e) => {
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
{/* Expanded Section Content */}
{expandedSections.has(section.id) && (
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
{/* Subheadings */}
{section.subheadings && section.subheadings.length > 0 && (
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
</div>
)}
{/* Generated Image Display */}
{sectionImages[section.id] && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
🖼 Generated Image
</h4>
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
maxWidth: '600px',
backgroundColor: 'white'
}}>
<img
src={`data:image/png;base64,${sectionImages[section.id]}`}
alt={`Generated image for ${section.heading}`}
style={{
width: '100%',
height: 'auto',
display: 'block'
}}
/>
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<button
onClick={(e) => {
e.stopPropagation();
setImageModalState({ open: true, sectionId: section.id });
}}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500
}}
>
Generate Image for this section
</button>
</div>
</div>
)}
</div>

View File

@@ -12,269 +12,11 @@ interface KeywordInputFormProps {
onTaskStart?: (taskId: string) => void;
}
// Separate component to manage form state
const ResearchForm: React.FC<{
prompt?: string;
onSubmit: (data: { keywords: string; blogLength: string }) => void;
onCancel: () => void;
}> = ({ prompt, onSubmit, onCancel }) => {
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
const hasValidInput = keywords.trim().length > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasValidInput) {
onSubmit({ keywords: keywords.trim(), blogLength });
} else {
window.alert('Please enter keywords or a topic to start research.');
}
};
return (
<form
onSubmit={handleSubmit}
style={{
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '12px',
border: '1px solid #e0e0e0',
margin: '8px 0'
}}
>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
🔍 Let's Research Your Blog Topic
</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
{prompt || 'Please provide the keywords or topic you want to research for your blog:'}
</p>
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
Keywords or Topic *
</label>
<input
type="text"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
onFocus={(e) => e.target.select()}
placeholder="e.g., artificial intelligence, machine learning, AI trends"
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #1976d2',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box'
}}
autoFocus
autoComplete="off"
spellCheck="false"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
Blog Length (words)
</label>
<select
value={blogLength}
onChange={(e) => setBlogLength(e.target.value)}
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #1976d2',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box'
}}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
<option value="1500">1500 words (Long blog)</option>
<option value="2000">2000+ words (Comprehensive guide)</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
<button
type="submit"
disabled={!hasValidInput}
style={{
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 20px',
cursor: hasValidInput ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
flex: 1
}}
>
🚀 Start Research {hasValidInput ? '(Enabled)' : '(Disabled)'}
</button>
<button
type="button"
onClick={onCancel}
style={{
backgroundColor: 'transparent',
border: '1px solid #ddd',
borderRadius: '6px',
padding: '10px 20px',
cursor: 'pointer',
fontSize: '14px',
color: '#666'
}}
>
Cancel
</button>
</div>
</form>
);
};
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// Keyword input action with Human-in-the-Loop
useCopilotActionTyped({
name: 'getResearchKeywords',
description: 'Get keywords from user for blog research',
parameters: [
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
],
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
if (status === 'complete') {
return (
<div style={{
padding: '16px',
backgroundColor: '#f0f8ff',
borderRadius: '8px',
border: '1px solid #1976d2'
}}>
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
✅ Research keywords received! Starting research...
</p>
</div>
);
}
return (
<ResearchForm
prompt={args.prompt}
onSubmit={(formData) => {
onKeywordsReceived?.(formData);
respond?.(JSON.stringify(formData));
}}
onCancel={() => respond?.('CANCEL')}
/>
);
}
});
// Research action that actually performs the research
useCopilotActionTyped({
name: 'performResearch',
description: 'Perform research with collected keywords and blog length',
parameters: [
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
],
handler: async ({ formData }: { formData: string }) => {
try {
const data = JSON.parse(formData);
const { keywords, blogLength } = data;
const keywordList = keywords.includes(',')
? keywords.split(',').map((k: string) => k.trim())
: [keywords.trim()]; // Preserve single phrases as-is
// Check frontend cache first
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
console.log('Frontend cache hit - returning cached result instantly');
onResearchComplete?.(cachedResult);
return {
success: true,
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
cached: true
};
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
// Store the blog length in localStorage for later use
localStorage.setItem('blog_length_target', blogLength);
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
onTaskStart?.(task_id); // Notify parent component to start polling
return {
success: true,
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
task_id: task_id
};
} catch (error) {
console.error(`Research failed: ${error}`);
return {
success: false,
message: `❌ Research failed: ${error}. Please try again with different keywords.`
};
}
},
render: ({ status }: any) => {
if (status === 'inProgress' || status === 'executing') {
return (
<div style={{
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e0e0e0',
margin: '8px 0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid #1976d2',
borderTop: '2px solid transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
</div>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
return null;
}
});
// This component now only provides polling functionality
// The keyword input form is handled by ResearchAction component
return (
<>
@@ -294,4 +36,4 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
);
};
export default KeywordInputForm;
export default KeywordInputForm;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
@@ -11,18 +11,38 @@ interface OutlineGeneratorProps {
const useCopilotActionTyped = useCopilotAction as any;
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
research,
onTaskStart,
onPollingStart,
onModalShow
}) => {
}, ref) => {
// Expose an imperative method to trigger outline generation directly (bypass LLM)
useImperativeHandle(ref, () => ({
generateNow: async () => {
if (!research) {
return { success: false, message: 'No research yet. Please research a topic first.' };
}
try {
onModalShow?.();
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
onTaskStart(task_id);
onPollingStart(task_id);
return { success: true, task_id };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, message: errorMessage };
}
}
}));
useCopilotActionTyped({
name: 'generateOutline',
description: 'Generate outline from research results using AI analysis',
parameters: [],
handler: async () => {
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
if (!research) {
return { success: false, message: 'No research yet. Please research a topic first.' };
}
try {
// Show progress modal immediately when user clicks "Create outline"
@@ -64,7 +84,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
}
},
render: ({ status }: any) => {
console.log('generateOutline render called with status:', status);
if (status === 'inProgress' || status === 'executing') {
return (
<div style={{
@@ -105,6 +124,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
});
return null; // This component only provides the copilot action
};
});
export default OutlineGenerator;

View File

@@ -0,0 +1,89 @@
import React from 'react';
export interface Phase {
id: string;
name: string;
icon: string;
description: string;
completed: boolean;
current: boolean;
disabled: boolean;
}
interface PhaseNavigationProps {
phases: Phase[];
onPhaseClick: (phaseId: string) => void;
currentPhase: string;
}
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
onPhaseClick,
currentPhase
}) => {
return (
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
padding: '8px 0',
flexWrap: 'wrap'
}}>
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
return (
<button
key={phase.id}
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '20px',
border: 'none',
fontSize: '14px',
fontWeight: '500',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isCurrent
? '#1976d2'
: isCompleted
? '#4caf50'
: isDisabled
? '#f5f5f5'
: '#e3f2fd',
color: isCurrent
? 'white'
: isCompleted
? 'white'
: isDisabled
? '#999'
: '#1976d2',
opacity: isDisabled ? 0.6 : 1,
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
transform: isCurrent ? 'translateY(-1px)' : 'none'
}}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '16px' }}>
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
</span>
)}
</button>
);
})}
</div>
);
};
export default PhaseNavigation;

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import PhaseNavigation from './PhaseNavigation';
import { Phase } from './PhaseNavigation';
// Test component to verify phase navigation functionality
export const PhaseNavigationTest: React.FC = () => {
const [currentPhase, setCurrentPhase] = useState<string>('research');
const testPhases: Phase[] = [
{
id: 'research',
name: 'Research',
icon: '🔍',
description: 'Research your topic and gather data',
completed: true,
current: currentPhase === 'research',
disabled: false
},
{
id: 'outline',
name: 'Outline',
icon: '📝',
description: 'Create and refine your blog outline',
completed: true,
current: currentPhase === 'outline',
disabled: false
},
{
id: 'content',
name: 'Content',
icon: '✍️',
description: 'Generate and edit your blog content',
completed: false,
current: currentPhase === 'content',
disabled: false
},
{
id: 'seo',
name: 'SEO',
icon: '📈',
description: 'Optimize for search engines',
completed: false,
current: currentPhase === 'seo',
disabled: true
},
{
id: 'publish',
name: 'Publish',
icon: '🚀',
description: 'Publish your blog post',
completed: false,
current: currentPhase === 'publish',
disabled: true
}
];
const handlePhaseClick = (phaseId: string) => {
setCurrentPhase(phaseId);
};
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h2>Phase Navigation Test</h2>
<p>Current Phase: <strong>{currentPhase}</strong></p>
<PhaseNavigation
phases={testPhases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
/>
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<h3>Phase Status:</h3>
<ul>
{testPhases.map(phase => (
<li key={phase.id}>
<strong>{phase.name}</strong>:
{phase.completed ? ' ✅ Completed' : ' ⏳ Pending'} |
{phase.current ? ' 🎯 Current' : ''} |
{phase.disabled ? ' 🚫 Disabled' : ' ✅ Enabled'}
</li>
))}
</ul>
</div>
</div>
);
};
export default PhaseNavigationTest;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchPolling } from '../../hooks/usePolling';
@@ -15,13 +15,18 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [forceUpdate, setForceUpdate] = useState<number>(0);
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
// Cache the result for future use
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
@@ -35,84 +40,170 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
}
});
useCopilotActionTyped({
name: 'showResearchForm',
description: 'Show keyword input form for blog research',
parameters: [],
handler: async () => ({
success: true,
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
showForm: true
}),
render: ({ status }: any) => {
const _ = forceUpdate;
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (polling.currentStatus === 'in_progress' || polling.currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
return (
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
<input
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
<option value="1500">1500 words (Long blog)</option>
<option value="2000">2000 words (Comprehensive blog)</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setForceUpdate(prev => prev + 1);
return;
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
setForceUpdate(prev => prev + 1);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
>
🚀 Start Research
</button>
</div>
</div>
);
}
});
// Additional action to catch the specific suggestion message
useCopilotActionTyped({
name: 'researchTopic',
description: 'Research topic with keywords and persona context using Google Search grounding',
parameters: [
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: false },
{ name: 'industry', type: 'string', description: 'Industry', required: false },
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
],
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
try {
// If keywords is a topic description, preserve as single phrase unless comma-separated
const keywordList = keywords.includes(',')
? keywords.split(',').map(k => k.trim())
: [keywords.trim()]; // Preserve single phrases as-is
const industryValue = industry || 'General';
const audienceValue = target_audience || 'General';
// Check frontend cache first
const cachedResult = researchCache.getCachedResult(keywordList, industryValue, audienceValue);
if (cachedResult) {
console.log('Frontend cache hit - returning cached result instantly');
onResearchComplete?.(cachedResult);
return {
success: true,
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
cached: true
};
const trimmed = keywords.trim();
if (!trimmed) {
return "Please provide keywords or a topic for research.";
}
const keywordList = trimmed.includes(',')
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
: [trimmed];
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: industryValue,
target_audience: audienceValue,
word_count_target: blogLength ? parseInt(blogLength) : 1000
industry,
target_audience,
word_count_target: parseInt(blogLength)
};
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
return {
success: true,
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
task_id: task_id
};
return "Starting research with your provided keywords.";
} catch (error) {
console.error(`Research failed: ${error}`);
return {
success: false,
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
};
console.error('Failed to start research:', error);
return "Failed to start research. Please try again.";
}
},
render: () => null
}
});
return (
<ResearchProgressModal
open={showProgressModal}
title="Research in progress"
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => setShowProgressModal(false)}
/>
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => setShowProgressModal(false)}
/>
)}
</>
);
};

View File

@@ -3,6 +3,7 @@ import { useResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { researchCache } from '../../services/researchCache';
import { debug } from '../../utils/debug';
interface ResearchPollingHandlerProps {
taskId: string | null;
@@ -19,11 +20,11 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
const polling = useResearchPolling({
onProgress: (message) => {
console.log('ResearchPollingHandler - Progress message received:', message);
debug.log('[ResearchPollingHandler] progress', { message });
setCurrentMessage(message);
},
onComplete: (result) => {
console.log('ResearchPollingHandler - Research completed:', result);
debug.log('[ResearchPollingHandler] complete');
// Cache the result for future use
if (result && result.keywords) {
@@ -39,7 +40,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
setCurrentMessage('');
},
onError: (error) => {
console.error('Research polling error:', error);
debug.error('[ResearchPollingHandler] error', error);
onError?.(error);
setCurrentMessage('');
}
@@ -61,14 +62,14 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
};
}, [polling]);
console.log('ResearchPollingHandler render:', {
taskId,
isPolling: polling.isPolling,
status: polling.currentStatus,
progressMessages: polling.progressMessages?.length,
currentMessage,
error: polling.error
});
// Only log on meaningful changes
useEffect(() => {
debug.log('[ResearchPollingHandler] state', {
isPolling: polling.isPolling,
status: polling.currentStatus,
progressCount: polling.progressMessages?.length || 0
});
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
// Render the unified research progress modal when a task is present
return (

View File

@@ -1,6 +1,6 @@
/**
* Keyword Analysis Component
*
*
* Displays comprehensive keyword analysis including keyword types, densities,
* missing keywords, over-optimization, and distribution analysis.
*/
@@ -15,7 +15,7 @@ import {
IconButton,
Tooltip
} from '@mui/material';
import {
import {
GpsFixed,
Search,
Warning
@@ -36,86 +36,140 @@ interface KeywordAnalysisProps {
};
}
const baseCardSx = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const subCard = (color: string) => ({
p: 2,
borderRadius: 2,
border: `1px solid ${color}`,
background: `linear-gradient(145deg, ${color}14, ${color}1f)`
});
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
const keywordData = detailedAnalysis?.keyword_analysis;
const renderDensityRow = (keyword: string, density: number) => {
const status = density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal';
const chipColor = density > 3 ? 'error' : density < 1 ? 'warning' : 'success';
return (
<Box
key={keyword}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
borderRadius: 2,
backgroundColor: '#f1f5f9'
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
{keyword}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{status}
</Typography>
<Chip label={`${density.toFixed(1)}%`} color={chipColor} size="small" sx={{ fontWeight: 600 }} />
</Box>
</Box>
);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GpsFixed sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Keyword Analysis
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Keyword Types Overview */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCardSx}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
Keyword Types Found
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
<Box sx={subCard('rgba(34,197,94,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
Primary Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.primary_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
<Box sx={subCard('rgba(59,130,246,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
Long-tail Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.long_tail_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ fontWeight: 600, borderColor: '#93c5fd', color: '#1d4ed8' }} />
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
<Box sx={subCard('rgba(168,85,247,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
Semantic Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.semantic_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" color="secondary" size="small" sx={{ fontWeight: 600, borderColor: '#d8b4fe' }} />
))}
</Box>
</Box>
</Grid>
</Grid>
</Paper>
{/* Keyword Densities */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
Keyword Densities
</Typography>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Keyword Density Analysis
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Shows how frequently each keyword appears in your content as a percentage of total words.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Optimal Range:</strong> 1-3% for primary keywords
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Too Low (&lt;1%):</strong> Keyword may not be prominent enough
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Too High (&gt;3%):</strong> Risk of keyword stuffing
</Typography>
</Box>
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalys
arrow
>
<IconButton size="small" sx={{ color: 'primary.main' }}>
<Search />
<Search fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
<Box key={keyword} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)' }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{keyword}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
</Typography>
<Chip
label={`${density.toFixed(1)}%`}
color={density > 3 ? 'error' : density < 1 ? 'warning' : 'success'}
size="small"
/>
</Box>
</Box>
))
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
) : (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
No keyword density data available. Make sure your research data includes target keywords.
</Typography>
)}
</Box>
</Paper>
{/* Missing Keywords */}
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'error.main' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#dc2626' }}>
Missing Keywords
</Typography>
<Tooltip
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
arrow
>
<IconButton size="small" sx={{ color: 'error.main' }}>
<Warning />
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
<IconButton size="small" sx={{ color: '#dc2626' }}>
<Warning fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
{keywordData.missing_keywords.map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
))}
</Box>
</Paper>
)}
{/* Over-Optimized Keywords */}
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'warning.main' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#d97706' }}>
Over-Optimized Keywords
</Typography>
<Tooltip
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
arrow
>
<IconButton size="small" sx={{ color: 'warning.main' }}>
<Warning />
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
<IconButton size="small" sx={{ color: '#d97706' }}>
<Warning fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
{keywordData.over_optimization.map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
))}
</Box>
</Paper>
)}
{/* Keyword Distribution Analysis */}
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
<Paper sx={baseCardSx}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
Keyword Distribution Analysis
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
"{keyword}"
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
<Box
key={keyword}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc'
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
{keyword}
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Density: {data.density?.toFixed(1)}%
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
In Headings: {data.in_headings ? 'Yes' : 'No'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
First Occurrence: Character {data.first_occurrence || 'Not found'}
</Typography>
</Grid>

View File

@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
return `${current}/${max}`;
};
// Consistent text input styling for better contrast
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#dadce0'
}
} as const;
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Core SEO Metadata
</Typography>
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
<Grid container spacing={3}>
{/* SEO Title */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
SEO Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
value={metadata.seo_title || ''}
onChange={handleTextFieldChange('seo_title')}
placeholder="Enter SEO-optimized title (50-60 characters)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
)
}}
/>
<Alert severity="info" sx={{ mt: 1 }}>
Include your primary keyword and make it compelling for clicks
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Include your primary keyword and keep between 5060 characters
</Typography>
</Paper>
</Grid>
{/* Meta Description */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Meta Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
value={metadata.meta_description || ''}
onChange={handleTextFieldChange('meta_description')}
placeholder="Enter compelling meta description (150-160 characters)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
)
}}
/>
<Alert severity="info" sx={{ mt: 1 }}>
Include a call-to-action and your primary keyword
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Aim for 150160 characters with a clear value proposition
</Typography>
</Paper>
</Grid>
{/* URL Slug */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<LinkIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
URL Slug
</Typography>
<Tooltip title="Copy to clipboard">
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
onChange={handleTextFieldChange('url_slug')}
placeholder="seo-friendly-url-slug"
helperText="Use lowercase letters, numbers, and hyphens only"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>
{/* Focus Keyword */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Focus Keyword
</Typography>
<Tooltip title="Copy to clipboard">
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
onChange={handleTextFieldChange('focus_keyword')}
placeholder="primary-keyword"
helperText="Your main SEO keyword for this post"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>
{/* Blog Tags */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TagIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Blog Tags
</Typography>
<Tooltip title="Copy to clipboard">
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Tags</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
<Select
multiple
value={metadata.blog_tags || []}
onChange={handleTagsChange('blog_tags')}
input={<OutlinedInput label="Tags" />}
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Add relevant tags for better categorization and discoverability
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Add 36 relevant tags for better categorization and discoverability
</Typography>
</Paper>
</Grid>
{/* Blog Categories */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<CategoryIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Blog Categories
</Typography>
<Tooltip title="Copy to clipboard">
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Categories</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
<Select
multiple
value={metadata.blog_categories || []}
onChange={handleTagsChange('blog_categories')}
input={<OutlinedInput label="Categories" />}
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Select 2-3 primary categories for your content
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Select 13 primary categories for your content
</Typography>
</Paper>
</Grid>
{/* Social Hashtags */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TagIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Social Hashtags
</Typography>
<Tooltip title="Copy to clipboard">
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Hashtags</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
<Select
multiple
value={metadata.social_hashtags || []}
onChange={handleTagsChange('social_hashtags')}
input={<OutlinedInput label="Hashtags" />}
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Include # symbol for social media platforms
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Include # symbol (e.g., #multimodalAI). 35 hashtags recommended.
</Typography>
</Paper>
</Grid>
{/* Reading Time */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Reading Time
</Typography>
<Tooltip title="Copy to clipboard">
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
}}
helperText="Estimated reading time for your content"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>

View File

@@ -12,28 +12,35 @@ import {
Box,
Typography,
Paper,
Grid,
Card,
CardContent,
Chip,
Alert
Tabs,
Tab,
Tooltip,
IconButton
} from '@mui/material';
import {
Search as SearchIcon,
Code as CodeIcon,
Facebook as FacebookIcon,
Twitter as TwitterIcon,
Google as GoogleIcon
Google as GoogleIcon,
Info as InfoIcon
} from '@mui/icons-material';
interface PreviewCardProps {
metadata: any;
blogTitle: string;
previewTabValue: string;
onPreviewTabChange: (value: string) => void;
}
export const PreviewCard: React.FC<PreviewCardProps> = ({
metadata,
blogTitle
blogTitle,
previewTabValue,
onPreviewTabChange
}) => {
const getCurrentDate = () => {
return new Date().toLocaleDateString('en-US', {
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Title with Tooltip */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Live Preview
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Live Preview
</Typography>
<Tooltip
title="This is how your blog post will appear in search results and social media platforms"
arrow
placement="top"
>
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={3}>
{/* Google Search Results Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GoogleIcon sx={{ color: '#4285F4' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Google Search Results
{/* Platform Sub-Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={previewTabValue}
onChange={(e, newValue) => onPreviewTabChange(newValue)}
variant="scrollable"
scrollButtons="auto"
sx={{
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 500,
minHeight: 48
},
'& .Mui-selected': {
fontWeight: 600
}
}}
>
<Tab
icon={<GoogleIcon />}
iconPosition="start"
label="Google Search Results"
value="google"
/>
<Tab
icon={<FacebookIcon />}
iconPosition="start"
label="Facebook Preview"
value="facebook"
/>
<Tab
icon={<TwitterIcon />}
iconPosition="start"
label="Twitter Preview"
value="twitter"
/>
<Tab
icon={<CodeIcon />}
iconPosition="start"
label="Rich Snippets Preview"
value="richsnippets"
/>
</Tabs>
</Box>
{/* Google Search Results Preview */}
{previewTabValue === 'google' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GoogleIcon sx={{ color: '#4285F4', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Google Search Results
</Typography>
</Box>
{/* Google SERP Preview - Light Theme (matches actual Google) */}
<Card
sx={{
background: '#ffffff',
border: 'none',
boxShadow: 'none',
maxWidth: 600
}}
>
<CardContent sx={{ p: 2.5 }}>
{/* URL - Google Blue */}
<Typography
variant="caption"
sx={{
color: '#202124',
fontSize: '14px',
lineHeight: 1.3,
mb: 0.5,
display: 'block',
fontFamily: 'arial, sans-serif'
}}
>
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
</Typography>
<Chip label="SERP Preview" size="small" color="primary" />
</Box>
{/* Title - Google Blue, hover underline */}
<Typography
variant="h6"
sx={{
color: '#1a0dab',
fontWeight: 400,
fontSize: '20px',
lineHeight: 1.3,
mb: 0.5,
cursor: 'pointer',
fontFamily: 'arial, sans-serif',
'&:hover': { textDecoration: 'underline' }
}}
>
{metadata.seo_title || blogTitle}
</Typography>
{/* Description - Google Gray */}
<Typography
variant="body2"
sx={{
color: '#4d5156',
lineHeight: 1.58,
fontSize: '14px',
fontFamily: 'arial, sans-serif',
mb: 1
}}
>
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
</Typography>
{/* Additional Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
<Typography
variant="caption"
sx={{
color: '#70757a',
fontSize: '14px',
fontFamily: 'arial, sans-serif'
}}
>
{getCurrentDate()}
</Typography>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
{metadata.reading_time || 5} min read
</Typography>
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
<>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
{metadata.blog_tags.slice(0, 2).join(', ')}
</Typography>
</>
)}
</Box>
</CardContent>
</Card>
</Paper>
)}
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
<CardContent sx={{ p: 2 }}>
{/* Facebook Preview */}
{previewTabValue === 'facebook' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1c1e21' }}>
Facebook Preview
</Typography>
<Chip label="Open Graph" size="small" sx={{ bgcolor: '#e7f3ff', color: '#1877F2' }} />
</Box>
{/* Facebook Card Preview */}
<Card
sx={{
border: '1px solid #dadde1',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 500,
background: '#ffffff',
overflow: 'hidden'
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f2f3f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #dadde1'
}}>
{metadata.open_graph?.image ? (
<Typography variant="caption" sx={{ color: '#65676b' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#65676b' }}>
No image set
</Typography>
)}
</Box>
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#1a0dab', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
<Typography
variant="caption"
sx={{
color: '#65676b',
fontSize: '12px',
mb: 0.75,
display: 'block',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
>
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography
variant="h6"
variant="subtitle1"
sx={{
color: '#1a0dab',
fontWeight: 400,
fontSize: '1.1rem',
lineHeight: 1.3,
mb: 1,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
fontWeight: 600,
mb: 1,
lineHeight: 1.33,
fontSize: '17px',
color: '#050505',
fontFamily: 'Helvetica, Arial, sans-serif'
}}
>
{metadata.seo_title || blogTitle}
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#4d5156', lineHeight: 1.4, mb: 1 }}>
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
<Typography
variant="body2"
sx={{
color: '#65676b',
lineHeight: 1.33,
fontSize: '15px',
fontFamily: 'Helvetica, Arial, sans-serif'
}}
>
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
</Box>
</CardContent>
</Card>
</Paper>
)}
{/* Twitter Preview */}
{previewTabValue === 'twitter' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f1419' }}>
Twitter Preview
</Typography>
<Chip label="Twitter Card" size="small" sx={{ bgcolor: '#e1f5fe', color: '#1DA1F2' }} />
</Box>
{/* Twitter Card Preview */}
<Card
sx={{
border: '1px solid #eff3f4',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 500,
background: '#ffffff',
overflow: 'hidden'
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f7f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #eff3f4'
}}>
{metadata.twitter_card?.image ? (
<Typography variant="caption" sx={{ color: '#536471' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#536471' }}>
No image set
</Typography>
)}
</Box>
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
{/* URL */}
<Typography
variant="caption"
sx={{
color: '#536471',
fontSize: '13px',
mb: 0.75,
display: 'block',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
</Typography>
{/* Additional Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{getCurrentDate()}
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.reading_time || 5} min read
</Typography>
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
<>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.blog_tags.slice(0, 2).join(', ')}
</Typography>
</>
)}
</Box>
</CardContent>
</Card>
<Alert severity="info" sx={{ mt: 2 }}>
This is how your blog post will appear in Google search results
</Alert>
</Paper>
</Grid>
{/* Social Media Previews */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Facebook Preview
</Typography>
<Chip label="Open Graph" size="small" color="primary" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 200,
bgcolor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{metadata.open_graph?.image ? 'Image loaded' : 'No image set'}
</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#65676b', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#65676b', lineHeight: 1.4 }}>
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
</Box>
</CardContent>
</Card>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Twitter Preview
</Typography>
<Chip label="Twitter Card" size="small" color="info" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 200,
bgcolor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{metadata.twitter_card?.image ? 'Image loaded' : 'No image set'}
</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#536471', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#536471', lineHeight: 1.4 }}>
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
{/* Twitter handle */}
{metadata.twitter_card?.site && (
<Typography variant="caption" sx={{ color: '#536471', mt: 1, display: 'block' }}>
{metadata.twitter_card.site}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Paper>
</Grid>
{/* Rich Snippets Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<CodeIcon sx={{ color: '#34A853' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Rich Snippets Preview
</Typography>
<Chip label="JSON-LD Schema" size="small" color="success" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
<CardContent sx={{ p: 2 }}>
{/* Article Schema Preview */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
</Typography>
<Chip label="Article" size="small" color="success" />
</Box>
<Typography variant="body2" sx={{ color: '#4d5156', mb: 2 }}>
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
{/* Title */}
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
mb: 1,
lineHeight: 1.33,
fontSize: '15px',
color: '#0f1419',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{metadata.json_ld_schema?.author?.name && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
By {metadata.json_ld_schema.author.name}
</Typography>
</Box>
)}
{metadata.json_ld_schema?.datePublished && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
</Typography>
</Box>
)}
{metadata.reading_time && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.reading_time} min read
</Typography>
</Box>
)}
{metadata.json_ld_schema?.wordCount && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.json_ld_schema.wordCount} words
</Typography>
</Box>
)}
</Box>
{/* Description */}
<Typography
variant="body2"
sx={{
color: '#536471',
lineHeight: 1.33,
fontSize: '15px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: '#4d5156', display: 'block', mb: 1 }}>
Keywords:
{/* Twitter handle */}
{metadata.twitter_card?.site && (
<Typography
variant="caption"
sx={{
color: '#536471',
mt: 1,
display: 'block',
fontSize: '13px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card.site}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Paper>
)}
{/* Rich Snippets Preview */}
{previewTabValue === 'richsnippets' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<CodeIcon sx={{ color: '#34A853', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Rich Snippets Preview
</Typography>
<Chip label="JSON-LD Schema" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
</Box>
{/* Rich Snippets Card */}
<Card
sx={{
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 600
}}
>
<CardContent sx={{ p: 3 }}>
{/* Article Schema Preview */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
</Typography>
<Chip label="Article" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
</Box>
<Typography
variant="body1"
sx={{
color: '#4d5156',
mb: 2,
lineHeight: 1.6,
fontSize: '14px'
}}
>
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
{metadata.json_ld_schema?.author?.name && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
By {metadata.json_ld_schema.author.name}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
<Chip key={index} label={keyword} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
</CardContent>
</Card>
<Alert severity="success" sx={{ mt: 2 }}>
Rich snippets help search engines understand your content and may display additional information in search results
</Alert>
</Paper>
</Grid>
{/* Metadata Summary */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon />
Metadata Summary
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(76, 175, 80, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'success.main' }}>
{metadata.optimization_score || 0}%
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Optimization Score
{metadata.json_ld_schema?.datePublished && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
</Typography>
</Box>
)}
{metadata.reading_time && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{metadata.reading_time} min read
</Typography>
</Box>
)}
{metadata.json_ld_schema?.wordCount && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{metadata.json_ld_schema.wordCount} words
</Typography>
</Box>
)}
</Box>
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" sx={{ color: '#70757a', display: 'block', mb: 1, fontWeight: 500 }}>
Keywords:
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
<Chip
key={index}
label={keyword}
size="small"
variant="outlined"
sx={{ borderColor: '#e0e0e0', color: '#4d5156' }}
/>
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(33, 150, 243, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
{metadata.reading_time || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Reading Time (min)
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(156, 39, 176, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'secondary.main' }}>
{metadata.blog_tags?.length || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Tags
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(255, 152, 0, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'warning.main' }}>
{metadata.blog_categories?.length || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Categories
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
)}
</CardContent>
</Card>
</Paper>
)}
</Box>
);
};

View File

@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
return `${current}/${max}`;
};
// Consistent text input styling for better contrast
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#dadce0'
}
} as const;
const openGraph = metadata.open_graph || {};
const twitterCard = metadata.twitter_card || {};
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<ShareIcon sx={{ color: 'primary.main' }} />
Social Media Metadata
</Typography>
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={3}>
{/* Open Graph Section */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<LinkedInIcon sx={{ color: '#0077B5' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Open Graph Tags
</Typography>
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.title || ''}
onChange={handleNestedFieldChange('open_graph', 'title')}
placeholder="Open Graph title (60 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.description || ''}
onChange={handleNestedFieldChange('open_graph', 'description')}
placeholder="Open Graph description (160 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Image URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.image || ''}
onChange={handleNestedFieldChange('open_graph', 'image')}
placeholder="https://example.com/image.jpg"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.url || ''}
onChange={handleNestedFieldChange('open_graph', 'url')}
placeholder="https://example.com/blog-post"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}>
Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
</Alert>
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
</Typography>
</Paper>
</Grid>
{/* Twitter Card Section */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Card Tags
</Typography>
<Chip label="Twitter & X" size="small" color="info" />
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.title || ''}
onChange={handleNestedFieldChange('twitter_card', 'title')}
placeholder="Twitter card title (70 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.description || ''}
onChange={handleNestedFieldChange('twitter_card', 'description')}
placeholder="Twitter card description (200 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Image URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.image || ''}
onChange={handleNestedFieldChange('twitter_card', 'image')}
placeholder="https://example.com/twitter-image.jpg"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Site Handle
</Typography>
<Tooltip title="Copy to clipboard">
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.site || ''}
onChange={handleNestedFieldChange('twitter_card', 'site')}
placeholder="@yourwebsite"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}>
Twitter cards provide rich previews when your content is shared on Twitter/X
</Alert>
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
Twitter cards provide rich previews when your content is shared on Twitter/X.
</Typography>
</Paper>
</Grid>
{/* Social Media Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<ShareIcon />
Social Media Preview
</Typography>
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
{/* Facebook Preview */}
<Grid item xs={12} md={6}>
<Card sx={{ border: '1px solid #e0e0e0' }}>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Facebook Preview
</Typography>
</Box>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
{openGraph.title || 'Your Blog Title'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
{openGraph.url || 'yourwebsite.com'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
{openGraph.description || 'Your meta description will appear here...'}
</Typography>
</Box>
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
{/* Twitter Preview */}
<Grid item xs={12} md={6}>
<Card sx={{ border: '1px solid #e0e0e0' }}>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Preview
</Typography>
</Box>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
{twitterCard.title || 'Your Blog Title'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
{twitterCard.site || '@yourwebsite'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
{twitterCard.description || 'Your Twitter description will appear here...'}
</Typography>
</Box>

View File

@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
}) => {
const [showRawJson, setShowRawJson] = useState(false);
// Helpers for counters and consistent input styling
const getCharacterCountColor = (current: number, max: number) => {
if (current > max) return 'error';
if (current > max * 0.9) return 'warning';
return 'success';
};
const getCharacterCountText = (current: number, max: number) => {
if (current > max) return `${current}/${max} (Too long)`;
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
return `${current}/${max}`;
};
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
}
} as const;
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
onMetadataEdit(field, event.target.value);
};
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid container spacing={3}>
{/* Article Information */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon />
Article Schema
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.headline || ''}
onChange={handleSchemaFieldChange('headline')}
placeholder="Article headline"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Typography
variant="caption"
color={getCharacterCountColor((jsonLdSchema.headline || '').length, 110)}
>
{getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
</Typography>
</InputAdornment>
)
}}
/>
</Grid>
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.description || ''}
onChange={handleSchemaFieldChange('description')}
placeholder="Article description"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Typography
variant="caption"
color={getCharacterCountColor((jsonLdSchema.description || '').length, 200)}
>
{getCharacterCountText((jsonLdSchema.description || '').length, 200)}
</Typography>
</InputAdornment>
)
}}
/>
</Grid>
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
</InputAdornment>
)
}}
sx={textInputSx}
/>
</Grid>
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
InputProps={{
endAdornment: <InputAdornment position="end">words</InputAdornment>
}}
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Author Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon />
Author Information
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={author.name || ''}
onChange={handleAuthorFieldChange('name')}
placeholder="Author Name"
sx={textInputSx}
/>
</Grid>
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={author['@type'] || ''}
onChange={handleAuthorFieldChange('@type')}
placeholder="Person"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publisher Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon />
Publisher Information
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={publisher.name || ''}
onChange={handlePublisherFieldChange('name')}
placeholder="Publisher Name"
sx={textInputSx}
/>
</Grid>
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={publisher.logo || ''}
onChange={handlePublisherFieldChange('logo')}
placeholder="https://example.com/logo.png"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publication Dates */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarIcon />
Publication Dates
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.datePublished || ''}
onChange={handleSchemaFieldChange('datePublished')}
InputLabelProps={{ shrink: true }}
sx={textInputSx}
/>
</Grid>
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.dateModified || ''}
onChange={handleSchemaFieldChange('dateModified')}
InputLabelProps={{ shrink: true }}
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Keywords */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon />
Keywords & Categories
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
}}
placeholder="keyword1, keyword2, keyword3"
helperText="Separate keywords with commas"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
readOnly: true,
sx: {
fontFamily: 'monospace',
fontSize: '0.875rem'
fontSize: '0.875rem',
background: '#0f172a',
color: '#e2e8f0'
}
}}
sx={{

View File

@@ -0,0 +1,264 @@
/**
* OverallScoreCard Component
*
* Renders the compact overall SEO score summary with grade chip and
* category score tiles.
*/
import React from 'react';
import {
Card,
CardHeader,
CardContent,
Typography,
Box,
Tooltip,
Paper,
Chip,
Avatar
} from '@mui/material';
interface MetricTooltip {
title: string;
description: string;
methodology: string;
score_meaning: string;
examples: string;
}
interface OverallScoreCardProps {
overallScore: number;
overallGrade: string;
statusLabel: string;
categoryScores: Record<string, number>;
getMetricTooltip: (category: string) => MetricTooltip;
getScoreColor: (score: number) => string;
}
const getGradeMeta = (grade: string) => {
switch (grade) {
case 'A':
return {
color: '#16a34a',
background: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.18))',
tooltip: 'Grade A: Outstanding SEO health with only minor optimizations needed.'
};
case 'B':
return {
color: '#0ea5e9',
background: 'linear-gradient(135deg, rgba(14,165,233,0.12), rgba(2,132,199,0.18))',
tooltip: 'Grade B: Strong SEO foundation with several opportunities to optimize further.'
};
case 'C':
return {
color: '#d97706',
background: 'linear-gradient(135deg, rgba(251,191,36,0.14), rgba(217,119,6,0.2))',
tooltip: 'Grade C: Moderate SEO performance. Prioritize improvements in weaker categories.'
};
case 'D':
return {
color: '#ea580c',
background: 'linear-gradient(135deg, rgba(251,113,133,0.14), rgba(249,115,22,0.2))',
tooltip: 'Grade D: Significant SEO gaps detected. Address critical issues promptly.'
};
default:
return {
color: '#475569',
background: 'linear-gradient(135deg, rgba(148,163,184,0.14), rgba(100,116,139,0.2))',
tooltip: 'SEO grade unavailable. Review analysis details for more information.'
};
}
};
export const OverallScoreCard: React.FC<OverallScoreCardProps> = ({
overallScore,
overallGrade,
statusLabel,
categoryScores,
getMetricTooltip,
getScoreColor
}) => {
const gradeMeta = getGradeMeta(overallGrade);
return (
<Card
sx={{
mb: 3,
background: 'rgba(255,255,255,0.95)',
border: '1px solid rgba(0,0,0,0.08)',
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
borderRadius: 3
}}
>
<CardHeader
sx={{
pb: 0,
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}
>
Overall SEO Performance Snapshot
</Typography>
</Box>
</CardHeader>
<CardContent
sx={{
pt: 2,
pb: { xs: 2.5, md: 3 },
px: { xs: 2, md: 3 }
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: { xs: 3, md: 4 },
alignItems: { xs: 'stretch', md: 'flex-start' }
}}
>
<Box
sx={{
flexShrink: 0,
minWidth: { md: 240 },
display: 'flex',
flexDirection: 'column',
alignItems: { xs: 'flex-start', md: 'center' },
gap: 1.5,
background: 'linear-gradient(145deg, rgba(241,245,249,0.7), rgba(255,255,255,0.95))',
borderRadius: 2,
p: { xs: 1.5, md: 2 }
}}
>
<Box sx={{ textAlign: { xs: 'left', md: 'center' } }}>
<Typography
component="span"
sx={{
display: 'inline-flex',
alignItems: 'baseline',
gap: 1,
fontWeight: 800,
fontSize: { xs: '2.4rem', md: '2.8rem' },
lineHeight: 1,
background: 'linear-gradient(120deg, #22c55e, #4ade80)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
{overallScore}
<Typography
component="span"
variant="caption"
sx={{ color: '#64748b', fontWeight: 600 }}
>
/100
</Typography>
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.5 }}>
Overall Score
</Typography>
</Box>
<Tooltip title={gradeMeta.tooltip} arrow placement="top">
<Chip
label={statusLabel}
avatar={
<Avatar
sx={{
bgcolor: '#fff',
color: gradeMeta.color,
fontWeight: 700
}}
>
{overallGrade}
</Avatar>
}
sx={{
fontWeight: 600,
px: 2.2,
py: 0.5,
letterSpacing: 0.3,
color: gradeMeta.color,
background: gradeMeta.background
}}
/>
</Tooltip>
</Box>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: 'repeat(2, minmax(110px, 1fr))', sm: 'repeat(3, minmax(110px, 1fr))' },
gap: 1.5
}}
>
{Object.entries(categoryScores).map(([category, score]) => {
const tooltip = getMetricTooltip(category);
return (
<Tooltip
key={category}
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 0.75, color: '#475569' }}>
{tooltip.description}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Methodology:</strong> {tooltip.methodology}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Score Meaning:</strong> {tooltip.score_meaning}
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Examples:</strong> {tooltip.examples}
</Typography>
</Box>
}
arrow
placement="top"
>
<Paper
sx={{
p: 1.4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
boxShadow: '0 8px 18px rgba(15,23,42,0.06)',
cursor: 'help'
}}
>
<Typography
variant="h6"
sx={{ fontWeight: 800, color: getScoreColor(score), mb: 0.35 }}
>
{score}
</Typography>
<Typography
variant="caption"
sx={{ color: '#64748b', textTransform: 'capitalize', fontWeight: 600 }}
>
{category.replace('_', ' ')}
</Typography>
</Paper>
</Tooltip>
);
})}
</Box>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default OverallScoreCard;

View File

@@ -1,6 +1,6 @@
/**
* Readability Analysis Component
*
*
* Displays comprehensive readability analysis including readability metrics,
* content statistics, sentence/paragraph analysis, and target audience information.
*/
@@ -14,7 +14,7 @@ import {
IconButton,
Tooltip
} from '@mui/material';
import {
import {
MenuBook
} from '@mui/icons-material';
@@ -57,109 +57,186 @@ interface ReadabilityAnalysisProps {
};
}
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
detailedAnalysis,
visualizationData
const cardStyles = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 30px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const sectionTitleSx = {
fontWeight: 700,
letterSpacing: 0.2,
color: '#0f172a',
mb: 2
} as const;
const statRowSx = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.5
} as const;
const statLabelSx = {
color: '#475569',
fontWeight: 500
} as const;
const statValueSx = {
color: '#0f172a',
fontWeight: 700
} as const;
const metricRowSx = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.65rem 0.85rem',
borderRadius: 12,
backgroundColor: '#f1f5f9',
cursor: 'help',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 10px 20px rgba(15,23,42,0.08)'
}
} as const;
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
detailedAnalysis,
visualizationData
}) => {
const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
const getMetricDetails = (metric: string, value: number) => {
const tooltips: Record<string, { description: string; interpretation: string }> = {
flesch_reading_ease: {
description: 'Measures how easy text is to read (0-100 scale).',
interpretation: value >= 80 ? 'Very Easy' : value >= 60 ? 'Standard' : 'Challenging'
},
flesch_kincaid_grade: {
description: 'U.S. grade level required to understand the text.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
gunning_fog: {
description: 'Years of formal education needed for comprehension.',
interpretation: value <= 12 ? 'Easy' : value <= 16 ? 'Moderate' : 'Advanced'
},
smog_index: {
description: 'Estimates the years of education needed to understand the text.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
automated_readability: {
description: 'Automated readability score based on characters per word.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
coleman_liau: {
description: 'Readability based on characters per word and sentence length.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
}
};
return (
tooltips[metric] || {
description: 'Readability metric',
interpretation: 'No interpretation available'
}
);
};
const renderStatRow = (label: React.ReactNode, value: React.ReactNode) => (
<Box sx={statRowSx}>
<Typography variant="body2" sx={statLabelSx}>
{label}
</Typography>
<Typography variant="body2" sx={statValueSx}>
{value}
</Typography>
</Box>
);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<MenuBook sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography
variant="h6"
component="h3"
sx={{ fontWeight: 700, letterSpacing: 0.3, color: '#0f172a' }}
>
Readability Analysis
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={cardStyles}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Readability Metrics
</Typography>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Readability Analysis
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Measures how easy your content is to read and understand.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Average Sentence Length:</strong> 15-20 words is optimal
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
<strong>Sentence Length:</strong> 15-20 words is optimal
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
</Typography>
</Box>
}
arrow
>
<IconButton size="small" sx={{ color: 'primary.main' }}>
<MenuBook />
<MenuBook fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
const getReadabilityTooltip = (metric: string, value: number) => {
const tooltips = {
flesch_reading_ease: {
description: "Measures how easy text is to read (0-100 scale)",
interpretation: value >= 80 ? "Very Easy" : value >= 60 ? "Standard" : "Difficult"
},
flesch_kincaid_grade: {
description: "U.S. grade level needed to understand the text",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
gunning_fog: {
description: "Years of formal education needed to understand the text",
interpretation: value <= 12 ? "Easy" : value <= 16 ? "Moderate" : "Difficult"
},
smog_index: {
description: "Simple Measure of Gobbledygook - readability formula",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
automated_readability: {
description: "Automated Readability Index based on character count",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
coleman_liau: {
description: "Readability test based on average sentence length and characters per word",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
}
};
return tooltips[metric as keyof typeof tooltips] || { description: "Readability metric", interpretation: "N/A" };
};
const tooltip = getReadabilityTooltip(metric, value);
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{Object.keys(readabilityMetrics).length > 0 ? (
Object.entries(readabilityMetrics).map(([metric, value]) => {
const { description, interpretation } = getMetricDetails(metric, value);
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
return (
<Tooltip
key={metric}
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{description}
</Typography>
<Typography variant="caption">
<strong>Interpretation:</strong> {tooltip.interpretation}
<Typography variant="caption" sx={{ color: '#64748b' }}>
<strong>Interpretation:</strong> {interpretation}
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)', cursor: 'help' }}>
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
<Box sx={metricRowSx}>
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
{metric.replace('_', ' ')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
{value.toFixed(1)}
</Typography>
</Box>
@@ -167,116 +244,72 @@ export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
);
})
) : (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
No readability metrics available. This may indicate an issue with the content analysis.
</Typography>
)}
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Content Statistics
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Word Count</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Sections</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Paragraphs</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Sentences</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Unique Words</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Vocabulary Diversity</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
{renderStatRow(
'Vocabulary Diversity',
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
: 'N/A'
)}
</Box>
</Paper>
</Grid>
</Grid>
{/* Additional Readability Metrics */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Sentence & Paragraph Analysis
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Avg Sentence Length</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Avg Paragraph Length</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Transition Words</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow(
'Average Sentence Length',
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
: 'N/A'
)}
{renderStatRow(
'Average Paragraph Length',
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
: 'N/A'
)}
{renderStatRow(
'Transition Words Used',
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
)}
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Target Audience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Reading Level</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Content Depth Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Flow Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
</Box>
</Paper>
</Grid>

View File

@@ -1,6 +1,6 @@
/**
* Recommendations Component
*
*
* Displays actionable SEO recommendations with priority indicators,
* category tags, and impact descriptions.
*/
@@ -12,7 +12,7 @@ import {
Paper,
Chip
} from '@mui/material';
import {
import {
Lightbulb,
CheckCircle,
Cancel,
@@ -30,78 +30,107 @@ interface RecommendationsProps {
recommendations: Recommendation[];
}
const priorityStyles: Record<string, { color: string; gradient: string }> = {
High: { color: '#dc2626', gradient: 'linear-gradient(135deg, rgba(248,113,113,0.12), rgba(239,68,68,0.18))' },
Medium: { color: '#d97706', gradient: 'linear-gradient(135deg, rgba(251,191,36,0.12), rgba(217,119,6,0.16))' },
Low: { color: '#16a34a', gradient: 'linear-gradient(135deg, rgba(74,222,128,0.12), rgba(22,163,74,0.16))' },
default: { color: '#475569', gradient: 'linear-gradient(135deg, rgba(148,163,184,0.1), rgba(100,116,139,0.14))' }
};
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'High': return 'error.main';
case 'Medium': return 'warning.main';
case 'Low': return 'success.main';
default: return 'text.secondary';
}
};
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
default: return <Warning sx={{ fontSize: 16 }} />;
case 'High':
return <Cancel sx={{ fontSize: 18 }} />;
case 'Medium':
return <Warning sx={{ fontSize: 18 }} />;
case 'Low':
return <CheckCircle sx={{ fontSize: 18 }} />;
default:
return <Warning sx={{ fontSize: 18 }} />;
}
};
const getScoreBadgeVariant = (score: number) => {
if (score >= 80) return 'success';
if (score >= 60) return 'warning';
return 'error';
const getChipColor = (priority: string) => {
switch (priority) {
case 'High':
return 'error';
case 'Medium':
return 'warning';
case 'Low':
return 'success';
default:
return 'default';
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Lightbulb sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Actionable Recommendations
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recommendations.map((rec, index) => (
<Paper
key={index}
sx={{
p: 3,
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
borderRadius: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
{getPriorityIcon(rec.priority)}
</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Chip
label={rec.category}
variant="outlined"
size="small"
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
/>
<Chip
label={rec.priority}
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
size="small"
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{recommendations.map((rec, index) => {
const styles = priorityStyles[rec.priority] || priorityStyles.default;
return (
<Paper
key={index}
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 3,
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
color: '#0f172a'
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box
sx={{
mt: 0.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '999px',
background: styles.gradient,
color: getPriorityColor(rec.priority)
}}
>
{getPriorityIcon(rec.priority)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Chip
label={rec.category}
variant="outlined"
size="small"
sx={{ borderColor: '#cbd5f5', color: '#475569', fontWeight: 600 }}
/>
<Chip
label={rec.priority}
color={getChipColor(rec.priority)}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
<Typography variant="body1" sx={{ lineHeight: 1.6, color: '#1f2937' }}>
{rec.recommendation}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{rec.impact}
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 1 }}>
{rec.recommendation}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{rec.impact}
</Typography>
</Box>
</Box>
</Paper>
))}
</Paper>
);
})}
</Box>
</Box>
);

View File

@@ -1,6 +1,6 @@
/**
* Structure Analysis Component
*
*
* Displays comprehensive content structure analysis including structure overview,
* content elements detection, and heading structure analysis.
*/
@@ -14,7 +14,7 @@ import {
Chip,
Tooltip
} from '@mui/material';
import {
import {
BarChart
} from '@mui/icons-material';
@@ -52,127 +52,157 @@ interface StructureAnalysisProps {
};
}
const baseCard = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const infoRow = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 0',
cursor: 'help'
} as const;
const statLabel = {
color: '#475569',
fontWeight: 500
} as const;
const statValue = {
color: '#0f172a',
fontWeight: 700
} as const;
const highlightCard = (borderColor: string) => ({
p: 2,
borderRadius: 2,
border: `1px solid ${borderColor}`,
background: `linear-gradient(140deg, ${borderColor}15, ${borderColor}22)`
});
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
const structure = detailedAnalysis?.content_structure;
const quality = detailedAnalysis?.content_quality;
const headings = detailedAnalysis?.heading_structure;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<BarChart sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Content Structure Analysis
</Typography>
</Box>
<Grid container spacing={3}>
{/* Content Structure Overview */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Structure Overview
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Sections
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Number of main content sections (H2 headings) in your blog post.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good sectioning improves readability and helps search engines understand your content structure.
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Sections</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_sections || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Paragraphs
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Number of paragraphs in your content (excluding headings).
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Appropriate paragraph count indicates good content depth and organization.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Paragraphs</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_paragraphs || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Sentences
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Total number of sentences in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Sentence count affects readability and content comprehensiveness.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Sentences</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_sentences || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Structure Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Overall score (0-100) for your content's structural organization.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Section count, paragraph count, introduction/conclusion presence
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Well-structured content ranks better and provides better user experience.
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/conclusion presence.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Structure Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.structure_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
{/* Content Elements */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Content Elements
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Introduction Section
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content has a clear introduction that sets context and expectations.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> Introductions help readers understand what they'll learn and improve engagement.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Clear introductions help search engines understand your content's purpose.
</Typography>
</Box>
}
title="Whether your content has a clear introduction that sets context and expectations."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Introduction</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
<Chip
label={structure?.has_introduction ? 'Yes' : 'No'}
color={structure?.has_introduction ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Conclusion Section
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content has a clear conclusion that summarizes key points.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> Conclusions help readers retain information and provide closure.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Good conclusions can improve time on page and reduce bounce rate.
</Typography>
</Box>
}
title="Whether your content ends with a clear conclusion summarizing key points."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Conclusion</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
<Chip
label={structure?.has_conclusion ? 'Yes' : 'No'}
color={structure?.has_conclusion ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Call to Action
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content includes a clear call to action for readers.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> CTAs guide readers to take desired actions and improve conversion rates.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Strong CTAs can improve user engagement metrics.
</Typography>
</Box>
}
title="Whether your content includes a clear call to action for readers."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Call to Action</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
<Chip
label={structure?.has_call_to_action ? 'Yes' : 'No'}
color={structure?.has_call_to_action ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
{/* Content Quality Metrics */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Content Quality Metrics
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Word Count
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Total number of words in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 800-2000 words for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Longer content typically ranks better and provides more value to readers.
</Typography>
</Box>
}
title="Total number of words in your content. Longer content typically ranks better."
arrow
>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
Word Count
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.word_count || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.word_count || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Vocabulary Diversity
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Ratio of unique words to total words, indicating content variety.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 0.4-0.7 (40-70% unique words)
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Higher diversity indicates richer, more engaging content.
</Typography>
</Box>
}
title="Ratio of unique words to total words, indicating content variety and richness."
arrow
>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
Vocabulary Diversity
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.vocabulary_diversity !== undefined
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
: 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Content Depth Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how comprehensive and detailed your content is.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Word count, section depth, information density
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Deeper content provides more value and ranks better in search results.
</Typography>
</Box>
}
title="Score (0-100) indicating how comprehensive and detailed your content is."
arrow
>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
Content Depth Score
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.content_depth_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Flow Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how well your content flows from one idea to the next.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Transition words, sentence variety, logical progression
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good flow improves readability and keeps readers engaged.
</Typography>
</Box>
}
title="Score (0-100) indicating how well your content flows from one idea to the next."
arrow
>
<Box sx={{ p: 2, background: 'rgba(255, 152, 0, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 152, 0, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main', mb: 1 }}>
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
Flow Score
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.flow_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Transition Words
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Number of transition words used to connect ideas and improve flow.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 5-15 transition words for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Transition words improve readability and help readers follow your logic.
</Typography>
</Box>
}
title="Number of transition words used higher values suggest smoother narrative flow."
arrow
>
<Box sx={{ p: 2, background: 'rgba(244, 67, 54, 0.1)', borderRadius: 2, border: '1px solid rgba(244, 67, 54, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'error.main', mb: 1 }}>
Transition Words
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
Transition Words Used
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.transition_words_used || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Unique Words
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Number of unique words used in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> More unique words indicate richer vocabulary and better content variety.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Diverse vocabulary can help with semantic SEO and topic coverage.
</Typography>
</Box>
}
title="Average unique words used throughout the article. Indicates lexical richness."
arrow
>
<Box sx={{ p: 2, background: 'rgba(0, 150, 136, 0.1)', borderRadius: 2, border: '1px solid rgba(0, 150, 136, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'info.main', mb: 1 }}>
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
Unique Words
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.unique_words || 'N/A'}
</Typography>
</Box>
</Tooltip>
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
</Grid>
{/* Heading Structure */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Heading Structure Analysis
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
{headings && (
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Heading Structure
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
H1 Headings
</Typography>
))}
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h1_count}
</Typography>
))}
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
</Typography>
)}
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
</Typography>
))}
{detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
</Typography>
)}
</Box>
</Grid>
</Grid>
<Box sx={{ mt: 2, p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Heading Hierarchy Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how well your heading structure follows SEO best practices.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> H1 presence, logical hierarchy, keyword usage in headings
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good heading structure helps search engines understand your content and improves readability.
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
</Typography>
</Box>
}
arrow
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, cursor: 'help' }}>
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
</Typography>
</Tooltip>
</Box>
{/* Structure Recommendations */}
{detailedAnalysis?.content_structure?.recommendations && detailedAnalysis.content_structure.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(255, 193, 7, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 193, 7, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'warning.main' }}>
Structure Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.content_structure.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
• {recommendation}
</Grid>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
H2 Headings
</Typography>
))}
</Box>
</Box>
)}
{/* Heading Recommendations */}
{detailedAnalysis?.heading_structure?.recommendations && detailedAnalysis.heading_structure.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'primary.main' }}>
Heading Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.heading_structure.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
• {recommendation}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h2_count}
</Typography>
))}
</Box>
</Box>
)}
{/* Content Quality Recommendations */}
{detailedAnalysis?.content_quality?.recommendations && detailedAnalysis.content_quality.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'success.main' }}>
Content Quality Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.content_quality.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
{recommendation}
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
</Typography>
))}
</Box>
</Box>
)}
</Paper>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0ea5e9', mb: 1 }}>
H3 Headings
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h3_count}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Grid>
)}
</Box>
);
};

View File

@@ -23,7 +23,9 @@ import {
Grid,
Paper,
IconButton,
Tooltip
Tooltip,
Avatar,
CircularProgress
} from '@mui/material';
import { apiClient } from '../../api/client';
import {
@@ -32,11 +34,11 @@ import {
Warning,
TrendingUp,
Search,
BarChart,
Refresh,
Close
} from '@mui/icons-material';
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
import OverallScoreCard from './SEO/OverallScoreCard';
interface SEOAnalysisResult {
overall_score: number;
@@ -139,7 +141,27 @@ interface SEOAnalysisModalProps {
blogContent: string;
blogTitle?: string;
researchData: any;
onApplyRecommendations?: (recommendations: any[]) => void;
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
// Simple content hashing helper (SHA-256)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getSeoCacheKey(contentHash: string, title?: string) {
return `seo_cache:${contentHash}:${title || ''}`;
}
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
blogContent,
blogTitle,
researchData,
onApplyRecommendations
onApplyRecommendations,
onAnalysisComplete
}) => {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const [progressMessage, setProgressMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState('recommendations');
const [contentHash, setContentHash] = useState<string>('');
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
// Debug logging
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
const runSEOAnalysis = useCallback(async () => {
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
try {
setIsAnalyzing(true);
setError(null);
setProgress(0);
setProgressMessage('Starting SEO analysis...');
// Simulate progress updates (in real implementation, this would be SSE)
// Cache check
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
const cacheKey = getSeoCacheKey(hash, blogTitle);
if (!forceRefresh) {
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
if (cached) {
const parsed = JSON.parse(cached);
setAnalysisResult(parsed as SEOAnalysisResult);
setIsAnalyzing(false);
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed as SEOAnalysisResult);
}
return;
}
}
// Simulated progress
const progressStages = [
{ progress: 20, message: 'Extracting keywords from research data...' },
{ progress: 40, message: 'Analyzing content structure and readability...' },
@@ -182,7 +224,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Make API call to analyze blog content
// Backend call
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
blog_content: blogContent,
blog_title: blogTitle,
@@ -191,15 +233,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const result = response.data;
console.log('🔍 Backend SEO Analysis Response:', result);
// Convert API response to frontend format - fail fast if data is missing
if (!result.success) {
throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
}
if (!result.overall_score && result.overall_score !== 0) {
throw new Error('Invalid SEO score received from API');
}
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
const convertedResult: SEOAnalysisResult = {
overall_score: result.overall_score,
@@ -256,13 +291,44 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
};
setAnalysisResult(convertedResult);
// Save to cache
try {
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
const key = getSeoCacheKey(h, blogTitle);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(convertedResult));
}
} catch {}
setIsAnalyzing(false);
// Notify parent that analysis is complete (fresh analysis)
if (onAnalysisComplete) {
onAnalysisComplete(convertedResult);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Analysis failed');
setIsAnalyzing(false);
}
}, [blogContent, blogTitle, researchData]);
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
// Precompute hash when modal opens
useEffect(() => {
if (isOpen) {
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
})();
}
}, [isOpen, blogContent, blogTitle]);
useEffect(() => {
if (isOpen && !analysisResult) {
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
const getScoreColor = (score: number) => {
if (score >= 80) return 'success.main';
@@ -270,13 +336,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
return 'error.main';
};
const getScoreBadgeVariant = (score: number) => {
if (score >= 80) return 'success';
if (score >= 60) return 'warning';
return 'error';
};
// Tooltip content for each metric
const getMetricTooltip = (category: string) => {
const tooltips = {
@@ -326,12 +385,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
};
useEffect(() => {
if (isOpen && !analysisResult) {
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
return (
<Dialog
open={isOpen}
@@ -342,14 +395,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
sx: {
maxHeight: '90vh',
borderRadius: 3,
background: 'rgba(255, 255, 255, 0.98)',
backgroundColor: '#f8fafc',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0,0,0,0.1)',
color: 'text.primary'
border: '1px solid rgba(148,163,184,0.25)',
color: '#0f172a'
}
}}
>
<DialogContent sx={{ p: 0 }}>
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
SEO Analysis Results
</Typography>
</Box>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={() => {
setAnalysisResult(null);
runSEOAnalysis(true);
}}
>
Refresh
</Button>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />
</IconButton>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
Comprehensive analysis of your blog content's SEO optimization
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
{analysisResult && (
<Box sx={{ p: 3 }}>
{/* Overall Score Section */}
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
<CardHeader>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BarChart sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
Overall SEO Score
</Typography>
</Box>
</CardHeader>
<CardContent>
<Grid container spacing={3} alignItems="center">
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h2"
sx={{
fontWeight: 'bold',
color: getScoreColor(analysisResult.overall_score),
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
{analysisResult.overall_score}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Overall Score
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{analysisResult.analysis_summary.overall_grade}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Grade
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Chip
label={analysisResult.analysis_summary.status}
color={getScoreBadgeVariant(analysisResult.overall_score)}
variant="filled"
sx={{
fontWeight: 600,
fontSize: '0.9rem',
px: 2,
py: 1
}}
/>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Category Scores */}
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
<CardHeader>
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
Category Breakdown
</Typography>
</CardHeader>
<CardContent>
<Grid container spacing={2}>
{Object.entries(analysisResult.category_scores).map(([category, score]) => {
const tooltip = getMetricTooltip(category);
return (
<Grid item xs={6} md={4} key={category}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
<strong>Methodology:</strong> {tooltip.methodology}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Score Meaning:</strong> {tooltip.score_meaning}
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Examples:</strong> {tooltip.examples}
</Typography>
</Box>
}
arrow
placement="top"
>
<Paper
sx={{
p: 2,
textAlign: 'center',
background: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 2,
cursor: 'help',
'&:hover': {
background: 'rgba(255,255,255,0.9)',
transform: 'translateY(-2px)',
transition: 'all 0.2s ease-in-out'
}
}}
>
<Typography
variant="h4"
sx={{
fontWeight: 'bold',
color: getScoreColor(score),
mb: 1
}}
>
{score}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
{category.replace('_', ' ')}
</Typography>
</Paper>
</Tooltip>
</Grid>
);
})}
</Grid>
</CardContent>
</Card>
<OverallScoreCard
overallScore={analysisResult.overall_score}
overallGrade={analysisResult.analysis_summary.overall_grade}
statusLabel={analysisResult.analysis_summary.status}
categoryScores={analysisResult.category_scores}
getMetricTooltip={getMetricTooltip}
getScoreColor={getScoreColor}
/>
{/* Detailed Analysis Tabs */}
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
@@ -603,43 +545,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TrendingUp sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
AI-Powered Insights
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Content Summary
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
{analysisResult.analysis_summary.ai_summary}
</Typography>
</Paper>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Key Strengths
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
<Typography variant="body2">{strength}</Typography>
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
</Box>
))}
</Box>
</Paper>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Areas for Improvement
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
<Typography variant="body2">{weakness}</Typography>
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
</Box>
))}
</Box>
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
{/* Action Buttons */}
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
{applyError && (
<Alert severity="error" sx={{ mb: 2 }}>
<Cancel sx={{ mr: 1 }} />
{applyError}
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }}>
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
Close
</Button>
<Button
variant="contained"
onClick={() => {
if (onApplyRecommendations) {
onApplyRecommendations(analysisResult.actionable_recommendations);
onClick={async () => {
if (!onApplyRecommendations) return;
setApplyError(null);
setIsApplying(true);
try {
await onApplyRecommendations(analysisResult.actionable_recommendations);
// Increased delay to ensure sections are fully updated and phase stays in SEO
setTimeout(() => {
onClose();
}, 200);
} catch (applyErr: any) {
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
} finally {
setIsApplying(false);
}
onClose();
}}
disabled={!onApplyRecommendations}
disabled={!onApplyRecommendations || isApplying}
sx={{
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
'&:hover': {
@@ -672,7 +628,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}
}}
>
Apply Recommendations
{isApplying ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" />
Applying...
</Box>
) : (
'Apply Recommendations'
)}
</Button>
</Box>
</Box>

View File

@@ -9,7 +9,7 @@
* - Integration with backend metadata generation
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogTitle,
@@ -23,7 +23,8 @@ import {
CircularProgress,
Alert,
IconButton,
Chip
Chip,
Tooltip
} from '@mui/material';
import {
Close as CloseIcon,
@@ -42,6 +43,7 @@ import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
import { subscribeImage } from '../../utils/imageBus';
interface SEOMetadataModalProps {
isOpen: boolean;
@@ -49,6 +51,8 @@ interface SEOMetadataModalProps {
blogContent: string;
blogTitle: string;
researchData: any;
outline?: any[]; // Add outline structure
seoAnalysis?: any; // Add SEO analysis results
onMetadataGenerated: (metadata: any) => void;
}
@@ -71,20 +75,55 @@ interface SEOMetadataResult {
error?: string;
}
// Cache helper functions (similar to SEOAnalysisModal)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getMetadataCacheKey(contentHash: string, title?: string): string {
return `seo_metadata_cache:${contentHash}:${title || ''}`;
}
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
isOpen,
onClose,
blogContent,
blogTitle,
researchData,
outline,
seoAnalysis,
onMetadataGenerated
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState('core');
const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first
const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms
const [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
const [contentHash, setContentHash] = useState<string>('');
// Subscribe to image generation bus to auto-fill OG/Twitter image fields
useEffect(() => {
const unsub = subscribeImage(({ base64 }: { base64: string }) => {
setEditableMetadata(prev => {
const next = { ...(prev || metadataResult || {}) } as any;
next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` };
next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` };
return next;
});
});
return unsub;
}, [metadataResult]);
// Debug logging
useEffect(() => {
@@ -96,19 +135,67 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
});
}, [isOpen, blogContent, blogTitle, researchData]);
const generateMetadata = async () => {
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes (but keep result for next time)
setError(null);
setIsGenerating(false);
}
}, [isOpen]);
// Auto-generate metadata when modal opens (only once)
const hasAutoGeneratedRef = React.useRef(false);
useEffect(() => {
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
hasAutoGeneratedRef.current = true;
generateMetadata(false); // Auto-generate from cache or API
}
if (!isOpen) {
hasAutoGeneratedRef.current = false; // Reset when modal closes
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); // Only trigger when modal opens
const generateMetadata = useCallback(async (forceRefresh = false) => {
try {
setIsGenerating(true);
setError(null);
setMetadataResult(null);
if (forceRefresh) {
setMetadataResult(null);
}
console.log('🚀 Starting SEO metadata generation...');
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
// Calculate content hash for caching
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(hash);
const cacheKey = getMetadataCacheKey(hash, blogTitle);
// Check cache first (unless force refresh)
if (!forceRefresh && typeof window !== 'undefined') {
const cached = window.localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached) as SEOMetadataResult;
console.log('✅ Using cached SEO metadata');
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
return;
} catch (e) {
console.warn('Failed to parse cached metadata:', e);
}
}
}
// Make API call to generate metadata
const response = await apiClient.post('/api/blog/seo/metadata', {
content: blogContent,
title: blogTitle,
research_data: researchData
research_data: researchData,
outline: outline || null,
seo_analysis: seoAnalysis || null
});
const result = response.data;
@@ -118,6 +205,16 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
throw new Error(result.error || 'Metadata generation failed');
}
// Cache the result
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(cacheKey, JSON.stringify(result));
console.log('💾 SEO metadata cached');
} catch (e) {
console.warn('Failed to cache metadata:', e);
}
}
setMetadataResult(result);
setEditableMetadata(result);
console.log('📊 Metadata result set:', result);
@@ -128,7 +225,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} finally {
setIsGenerating(false);
}
};
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
@@ -159,6 +256,23 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
};
/**
* Handle Apply Metadata button click
*
* This saves the generated/edited metadata to the parent component's state.
* The metadata is then used when publishing to platforms:
* - WordPress: Requires SEO metadata for proper post creation with SEO fields
* - Wix: Currently doesn't require metadata, but could be added in future
*
* The metadata includes:
* - SEO title, meta description, URL slug
* - Blog tags, categories, focus keyword
* - Open Graph tags (Facebook/LinkedIn)
* - Twitter Card tags
* - JSON-LD structured data (Schema.org Article)
*
* All of these will be passed to the platform's API when publishing.
*/
const handleApplyMetadata = () => {
if (editableMetadata) {
onMetadataGenerated(editableMetadata);
@@ -222,32 +336,26 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
/>
)}
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{metadataResult && (
<Tooltip title="Regenerate SEO metadata">
<IconButton
onClick={() => generateMetadata(true)}
size="small"
disabled={isGenerating}
color="primary"
>
<RefreshIcon />
</IconButton>
</Tooltip>
)}
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
{!metadataResult && !isGenerating && (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Generate Comprehensive SEO Metadata
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: 'text.secondary' }}>
Create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.
</Typography>
<Button
variant="contained"
size="large"
onClick={generateMetadata}
startIcon={<RefreshIcon />}
sx={{ px: 4 }}
>
Generate SEO Metadata
</Button>
</Box>
)}
{isGenerating && (
<Box sx={{ p: 4, textAlign: 'center' }}>
<CircularProgress size={60} sx={{ mb: 2 }} />
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
</Alert>
<Button
variant="outlined"
onClick={generateMetadata}
onClick={() => generateMetadata(true)}
startIcon={<RefreshIcon />}
>
Try Again
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
scrollButtons="auto"
sx={{ minHeight: 48 }}
>
{['core', 'social', 'structured', 'preview'].map((tab) => (
{['preview', 'core', 'social', 'structured'].map((tab) => (
<Tab
key={tab}
value={tab}
@@ -332,6 +440,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
<PreviewCard
metadata={editableMetadata || metadataResult}
blogTitle={blogTitle}
previewTabValue={previewTabValue}
onPreviewTabChange={setPreviewTabValue}
/>
)}
</Box>

View File

@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
return (
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
<div>Score: {analysis.seo_score}</div>
{!!analysis.recommendations?.length && (
<div>Score: {analysis.overall_score}</div>
{!!analysis.analysis_summary && (
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
Grade {analysis.analysis_summary.overall_grade} · {analysis.analysis_summary.status}
</div>
)}
{!!analysis.actionable_recommendations?.length && (
<ul>
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
{analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
<li key={index}>
<strong>{rec.category}:</strong> {rec.recommendation}
</li>
))}
</ul>
)}
</div>

View File

@@ -13,17 +13,35 @@ interface SuggestionsGeneratorProps {
contentConfirmed?: boolean;
}
export const useSuggestions = (
research: BlogResearchResponse | null,
outline: BlogOutlineSection[],
outlineConfirmed: boolean = false,
researchPolling?: { isPolling: boolean; currentStatus: string },
outlinePolling?: { isPolling: boolean; currentStatus: string },
mediumPolling?: { isPolling: boolean; currentStatus: string },
hasContent: boolean = false,
flowAnalysisCompleted: boolean = false,
contentConfirmed: boolean = false
) => {
interface SuggestionContext {
research: BlogResearchResponse | null;
outline: BlogOutlineSection[];
outlineConfirmed?: boolean;
researchPolling?: { isPolling: boolean; currentStatus: string };
outlinePolling?: { isPolling: boolean; currentStatus: string };
mediumPolling?: { isPolling: boolean; currentStatus: string };
hasContent?: boolean;
flowAnalysisCompleted?: boolean;
contentConfirmed?: boolean;
seoAnalysis?: any;
seoMetadata?: any;
seoRecommendationsApplied?: boolean;
}
export const useSuggestions = ({
research,
outline,
outlineConfirmed = false,
researchPolling,
outlinePolling,
mediumPolling,
hasContent = false,
flowAnalysisCompleted = false,
contentConfirmed = false,
seoAnalysis = null,
seoMetadata = null,
seoRecommendationsApplied = false
}: SuggestionContext) => {
return useMemo(() => {
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
@@ -66,14 +84,14 @@ export const useSuggestions = (
if (!research) {
items.push({
title: '🔎 Start Research',
message: "I want to research a topic for my blog",
message: "showResearchForm",
priority: 'high'
});
} else if (research && outline.length === 0) {
// Research completed, guide user to outline creation
items.push({
title: 'Next: Create Outline',
message: 'Let\'s proceed to create an outline based on the research results',
message: 'Research is complete. Please generate the blog outline now using the existing research data. Use the generateOutline action immediately without asking for additional information.',
priority: 'high'
});
items.push({
@@ -82,13 +100,13 @@ export const useSuggestions = (
});
items.push({
title: '🎨 Create Custom Outline',
message: 'I want to create an outline with my own specific instructions and requirements'
message: 'I want to create an outline with my own specific instructions and requirements. Please ask me for my custom requirements.'
});
} else if (outline.length > 0 && !outlineConfirmed) {
// Outline created but not confirmed - focus on outline review and confirmation
items.push({
title: 'Next: Confirm & Generate Content',
message: 'I confirm the outline and am ready to generate content',
message: 'The outline is ready. Confirm the current outline and begin content generation now. Call confirmOutlineAndGenerateContent immediately and do not ask for extra confirmation.',
priority: 'high'
});
items.push({
@@ -106,12 +124,6 @@ export const useSuggestions = (
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation and optimization
if (hasContent && !contentConfirmed) {
// User has content but hasn't confirmed it yet - show content review suggestions
items.push({
title: 'Next: Confirm Blog Content',
message: 'I have reviewed and confirmed my blog content is ready for the next stage',
priority: 'high'
});
items.push({
title: '🔄 ReWrite Blog',
message: 'I want to rewrite my blog with different approach, tone, or focus'
@@ -121,24 +133,78 @@ export const useSuggestions = (
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
items.push({
title: '📈 Run SEO Analysis',
message: 'Analyze SEO for my blog post'
title: 'Next: Run SEO Analysis',
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
});
} else if (hasContent && contentConfirmed) {
// Content confirmed - move to SEO stage
items.push({
title: '📈 Run SEO Analysis',
message: 'Analyze SEO for my blog post',
priority: 'high'
});
items.push({
title: '🧾 Generate SEO Metadata',
message: 'Generate SEO metadata and title'
});
items.push({
title: '🚀 Publish to WordPress',
message: 'Publish my blog to WordPress'
});
if (!seoAnalysis) {
// Prompt to run SEO analysis first
items.push({
title: 'Next: Run SEO Analysis',
message: 'The blog content is confirmed. Execute analyzeSEO immediately to launch the SEO analysis modal without further prompts.',
priority: 'high'
});
items.push({
title: 'Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
items.push({
title: 'Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
} else if (seoAnalysis && !seoRecommendationsApplied) {
// SEO analysis exists but recommendations not applied yet
items.push({
title: 'Next: Apply SEO Recommendations',
message: 'Open the SEO analysis modal and apply the actionable recommendations right away. Call analyzeSEO to reopen the modal without extra questions.',
priority: 'high'
});
items.push({
title: 'Content Analysis',
message: 'Run analyzeContentQuality to review narrative flow and get final improvement suggestions before publishing.'
});
items.push({
title: '📈 Review SEO Analysis',
message: 'Show me the latest SEO analysis results again by running analyzeSEO.'
});
} else if (seoAnalysis && seoRecommendationsApplied) {
// SEO analysis exists and recommendations applied - show next steps
if (!seoMetadata) {
items.push({
title: 'Next: Generate SEO Metadata',
message: 'SEO recommendations are applied. Execute generateSEOMetadata immediately so we can prepare titles, descriptions, and schema without further prompts.',
priority: 'high'
});
} else {
items.push({
title: 'Next: Publish',
message: 'The blog is SEO-optimized. Use publishToPlatform with your preferred destination (wix|wordpress) right away—no additional confirmation needed.',
priority: 'high'
});
}
items.push({
title: 'Content Analysis',
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
});
items.push({
title: 'Publish',
message: seoMetadata
? 'Publish my blog to your preferred platform using publishToPlatform.'
: 'Generate SEO metadata first, then publish your blog.'
});
if (seoMetadata) {
items.push({
title: '🚀 Publish to Wix',
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".'
});
items.push({
title: '🌐 Publish to WordPress',
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".'
});
}
}
} else {
// No content yet, show generation option
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
@@ -146,11 +212,24 @@ export const useSuggestions = (
}
return items;
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling, hasContent, flowAnalysisCompleted, contentConfirmed]);
}, [
research,
outline,
outlineConfirmed,
researchPolling,
outlinePolling,
mediumPolling,
hasContent,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied
]);
};
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
useSuggestions(research, outline, outlineConfirmed);
useSuggestions({ research, outline, outlineConfirmed });
return null; // This is just a utility component
};

View File

@@ -70,8 +70,21 @@ const BlogSection: React.FC<BlogSectionProps> = ({
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
const currentContent = textarea.value;
const updatedContent = currentContent.replace(originalText, newText);
// For smart suggestions, newText is already the complete updated content with insertion
// For other edits (like text selection improvements), we need to replace originalText with newText
let updatedContent: string;
if (editType === 'smart-suggestion') {
// newText already contains the full content with suggestion inserted
updatedContent = newText;
} else {
// For other edits, replace the selected text
const currentContent = textarea.value;
updatedContent = currentContent.replace(originalText, newText);
}
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
setContent(updatedContent);
// Update parent state
@@ -79,14 +92,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
onContentUpdate([{ id, content: updatedContent }]);
}
// Focus back to textarea and set cursor after the replaced text
setTimeout(() => {
if (contentRef.current) {
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
contentRef.current.focus();
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 100);
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
// For other edits, we may need to handle cursor positioning here if needed
}
}
);

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import TextSelectionMenu from './TextSelectionMenu';
import useSmartTypingAssist from './SmartTypingAssist';
import { debug } from '../../../utils/debug';
interface BlogTextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
@@ -281,12 +282,15 @@ const useBlogTextSelectionHandler = (
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
allSuggestions={smartTypingAssist.allSuggestions}
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
/>
)
};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { debug } from '../../../utils/debug';
interface SmartTypingAssistProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastGeneratedAtRef = useRef<number>(0);
// Quality improvement tracking
const [suggestionStats, setSuggestionStats] = useState({
@@ -52,25 +55,25 @@ const useSmartTypingAssist = (
// Smart typing assist functionality
const generateSmartSuggestion = async (currentText: string) => {
console.log('🔍 [SmartTypingAssist] generateSmartSuggestion called with text length:', currentText.length);
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
if (currentText.length < 20) {
console.log('🔍 [SmartTypingAssist] Text too short for suggestion');
debug.log('[SmartTypingAssist] Text too short for suggestion');
return; // Only suggest after some meaningful content
}
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
debug.log('[SmartTypingAssist] Starting suggestion generation...');
setIsGeneratingSuggestion(true);
try {
// Import the assistive writing API
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
console.log('🔍 [SmartTypingAssist] Calling assistive writing API...');
debug.log('[SmartTypingAssist] Calling assistive writing API...');
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
if (response.success && response.suggestions.length > 0) {
console.log('🔍 [SmartTypingAssist] Received', response.suggestions.length, 'suggestions from API');
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
// Store all suggestions
setAllSuggestions(response.suggestions);
@@ -78,7 +81,7 @@ const useSmartTypingAssist = (
// Show first suggestion
const firstSuggestion = response.suggestions[0];
console.log('🔍 [SmartTypingAssist] Showing first suggestion:', firstSuggestion.text.substring(0, 50) + '...');
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
// Track suggestion shown
setSuggestionStats(prev => ({
@@ -86,12 +89,30 @@ const useSmartTypingAssist = (
totalShown: prev.totalShown + 1
}));
// Get cursor position for suggestion placement
// Get viewport-safe position for suggestion placement
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
const y = Math.max(20, rect.bottom + 10);
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: firstSuggestion.text,
@@ -101,7 +122,7 @@ const useSmartTypingAssist = (
});
}
} else {
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
debug.log('[SmartTypingAssist] No suggestions received from API');
// Fallback to generic suggestions if API fails
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
@@ -116,8 +137,26 @@ const useSmartTypingAssist = (
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: randomSuggestion,
@@ -126,7 +165,7 @@ const useSmartTypingAssist = (
}
}
} catch (error) {
console.error('🔍 [SmartTypingAssist] Failed to generate smart suggestion:', error);
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
// Fallback to generic suggestions on error
const fallbackSuggestions = [
@@ -142,8 +181,14 @@ const useSmartTypingAssist = (
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
const maxWidth = 420;
const maxHeight = 160;
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 5;
if (y > window.innerHeight - maxHeight) {
y = window.innerHeight - (maxHeight + 20);
x = Math.max(20, window.innerWidth - (maxWidth + 20));
}
setSmartSuggestion({
text: randomSuggestion,
@@ -156,7 +201,7 @@ const useSmartTypingAssist = (
};
const handleTypingChange = (newText: string) => {
console.log('🔍 [SmartTypingAssist] handleTypingChange called with text length:', newText.length);
// Not logging this as it fires on every keystroke - too noisy
// Clear existing timeout
if (typingTimeoutRef.current) {
@@ -168,29 +213,45 @@ const useSmartTypingAssist = (
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
console.log('🔍 [SmartTypingAssist] Typing timeout triggered, text length:', newText.length, 'hasShownFirstSuggestion:', hasShownFirstSuggestion);
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
// First time suggestion appears automatically
if (!hasShownFirstSuggestion && newText.length > 20) {
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
const cooldownMs = 15000; // 15s cooldown between suggestions
const now = Date.now();
const sinceLast = now - lastGeneratedAtRef.current;
// First time suggestion appears automatically with sufficient content
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
debug.log('[SmartTypingAssist] Generating first suggestion');
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
lastGeneratedAtRef.current = now;
}
// After first time, only suggest after longer pauses or more content
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
generateSmartSuggestion(newText);
} else {
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
// After first time, show "Continue writing" prompt instead of random suggestions
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
setShowContinueWritingPrompt(true);
}
// Removed verbose log about skipping prompts as it's too noisy
}, 3000); // 3 second pause before suggesting
};
const handleAcceptSuggestion = () => {
if (smartSuggestion && onTextReplace && contentRef.current) {
const element = contentRef.current;
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
const newContent = currentContent + ' ' + smartSuggestion.text;
const element = contentRef.current as HTMLTextAreaElement;
const currentContent = element.value || '';
// Get cursor position
const cursorPosition = element.selectionStart || currentContent.length;
debug.log('[SmartTypingAssist] Cursor position', { cursorPosition, contentLength: currentContent.length });
// Insert suggestion at cursor position
const beforeCursor = currentContent.substring(0, cursorPosition);
const afterCursor = currentContent.substring(cursorPosition);
const suggestionWithSpace = ' ' + smartSuggestion.text + ' ';
const newContent = beforeCursor + suggestionWithSpace + afterCursor;
// Calculate where cursor should be after insertion (right after the suggestion)
const newCursorPosition = cursorPosition + suggestionWithSpace.length;
// Track suggestion accepted
setSuggestionStats(prev => ({
@@ -198,14 +259,21 @@ const useSmartTypingAssist = (
totalAccepted: prev.totalAccepted + 1
}));
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
...suggestionStats,
totalAccepted: suggestionStats.totalAccepted + 1
});
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
// Use the text replacement callback
onTextReplace(currentContent, newContent, 'smart-suggestion');
// Set cursor position after the inserted text
setTimeout(() => {
if (contentRef.current) {
const el = contentRef.current as HTMLTextAreaElement;
el.focus();
el.setSelectionRange(newCursorPosition, newCursorPosition);
debug.log('[SmartTypingAssist] Cursor positioned', { position: newCursorPosition });
}
}, 50);
setSmartSuggestion(null);
}
};
@@ -217,10 +285,7 @@ const useSmartTypingAssist = (
totalRejected: prev.totalRejected + 1
}));
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
...suggestionStats,
totalRejected: suggestionStats.totalRejected + 1
});
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
setSmartSuggestion(null);
setAllSuggestions([]);
@@ -238,11 +303,8 @@ const useSmartTypingAssist = (
totalCycled: prev.totalCycled + 1
}));
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
...suggestionStats,
totalCycled: suggestionStats.totalCycled + 1
});
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
setSuggestionIndex(nextIndex);
setSmartSuggestion(prev => prev ? {
@@ -254,6 +316,25 @@ const useSmartTypingAssist = (
}
};
// Handle "Continue writing" button click
const handleRequestSuggestion = async () => {
if (!contentRef.current) return;
const element = contentRef.current as HTMLTextAreaElement;
const currentContent = element.value || '';
setShowContinueWritingPrompt(false);
if (currentContent.length > 20) {
await generateSmartSuggestion(currentContent);
}
};
// Handle dismissing the "Continue writing" prompt
const handleDismissPrompt = () => {
setShowContinueWritingPrompt(false);
};
// Get suggestion statistics for quality improvement
const getSuggestionStats = () => {
const acceptanceRate = suggestionStats.totalShown > 0
@@ -284,10 +365,13 @@ const useSmartTypingAssist = (
allSuggestions,
suggestionIndex,
suggestionStats: getSuggestionStats(),
showContinueWritingPrompt,
handleTypingChange,
handleAcceptSuggestion,
handleRejectSuggestion,
handleNextSuggestion,
handleRequestSuggestion,
handleDismissPrompt,
getSuggestionStats,
generateSmartSuggestion
};

View File

@@ -34,12 +34,15 @@ interface TextSelectionMenuProps {
}>;
}>;
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
onRejectSuggestion: () => void;
onNextSuggestion: () => void;
onRequestSuggestion: () => void;
onDismissPrompt: () => void;
}
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
isGeneratingSuggestion,
allSuggestions,
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
onRejectSuggestion,
onNextSuggestion
onNextSuggestion,
onRequestSuggestion,
onDismissPrompt
}) => {
return (
<>
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(12px)',
zIndex: 10002,
maxWidth: '400px',
maxWidth: '420px',
minWidth: '320px',
maxHeight: '350px',
overflow: 'auto',
color: 'white'
}}
>
@@ -540,6 +548,93 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
</div>
)}
{/* Continue Writing Prompt */}
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(59, 130, 246, 0.95)',
color: 'white',
padding: '16px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
minWidth: '280px',
maxWidth: '360px'
}}>
<div style={{
fontSize: '13px',
fontWeight: '600',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
AI Writing Assistant
</div>
<div style={{
fontSize: '12px',
opacity: 0.9,
marginBottom: '16px',
lineHeight: '1.5'
}}>
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
</div>
<div style={{
display: 'flex',
gap: '8px'
}}>
<button
onClick={onRequestSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Continue Writing
</button>
<button
onClick={onDismissPrompt}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}}
>
</button>
</div>
</div>
)}
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {

View File

@@ -0,0 +1,297 @@
import React, { useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { Box, Button, MenuItem, Select, TextField, Typography, FormControl, InputLabel, Grid, Card, CardMedia, CircularProgress, LinearProgress, Collapse, IconButton, Tabs, Tab, Tooltip } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useImageGeneration, ImageGenerationRequest, fetchPromptSuggestions } from './useImageGeneration';
type Provider = 'gemini' | 'huggingface' | 'stability';
interface ImageGeneratorProps {
defaultProvider?: Provider;
defaultModel?: string;
defaultPrompt?: string;
onImageReady?: (base64: string) => void;
// Optional context to build SME, provider-tailored prompts
context?: {
title?: string | null;
outline?: any[];
research?: any;
persona?: { audience?: string; tone?: string; industry?: string } | any;
section?: {
heading?: string;
subheadings?: string[];
key_points?: string[];
keywords?: string[];
[key: string]: any;
};
};
}
export interface ImageGeneratorHandle {
suggest: () => Promise<void> | void;
generate: () => Promise<void> | void;
openAdvanced: () => void;
closeAdvanced: () => void;
}
export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGeneratorProps>((
{ defaultProvider, defaultModel, defaultPrompt, onImageReady, context },
ref
) => {
const [provider, setProvider] = useState<Provider>(defaultProvider || (process.env.NEXT_PUBLIC_GPT_PROVIDER as Provider) || 'huggingface');
const [model, setModel] = useState<string>(defaultModel || 'black-forest-labs/FLUX.1-Krea-dev');
const [prompt, setPrompt] = useState<string>(defaultPrompt || '');
const [negative, setNegative] = useState<string>('');
const [width, setWidth] = useState<number>(1024);
const [height, setHeight] = useState<number>(1024);
const [showAdvanced, setShowAdvanced] = useState(false);
const { isGenerating, error, result, generate } = useImageGeneration();
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ prompt: string; negative_prompt?: string; width?: number; height?: number; overlay_text?: string }>>([]);
const [suggestionIndex, setSuggestionIndex] = useState<number>(0);
const canGenerate = useMemo(() => prompt.trim().length > 0 && !isGenerating, [prompt, isGenerating]);
// High-contrast input styling for readability on light backgrounds
const textInputSx = {
'& .MuiInputBase-input': { color: '#202124' },
'& .MuiInputLabel-root': { color: '#5f6368' },
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#94a3b8' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' },
backgroundColor: '#ffffff'
} as const;
// Default negative prompts by provider for blog writer use-case
useEffect(() => {
if (negative.trim().length > 0) return;
if (provider === 'huggingface') {
setNegative('blurry, distorted, cartoon, low quality, bad anatomy, extra limbs, watermark, brand logos, text artifacts, oversaturated, noisy, jpeg artifacts');
} else if (provider === 'gemini') {
setNegative('cartoon, clip-art, abstract, noisy, low resolution, artifacts, watermark, brand logos, text artifacts');
} else {
setNegative('blurry, distorted, low quality, bad anatomy, extra limbs, watermark, brand logos, jpeg artifacts, oversharpened, text artifacts');
}
// run once on mount (and when provider changes if negative is empty)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
// Auto-suggest on open for better defaults (only if no initial prompt)
useEffect(() => {
if (!prompt || prompt.trim().length === 0) {
// fire and forget; UI shows spinner on the button if user clicks again
suggestPrompt().catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Provider-specialized prompt suggestions using backend structured response; fallback locally
const suggestPrompt = async () => {
setLoadingSuggestions(true);
try {
const payload = {
provider,
title: context?.title || context?.section?.heading || defaultPrompt || '',
section: context?.section || undefined,
research: context?.research || undefined,
persona: context?.persona || undefined,
};
const suggs = await fetchPromptSuggestions(payload);
setSuggestions(suggs);
if (suggs.length > 0) {
setPrompt(suggs[0].prompt || '');
if (suggs[0].negative_prompt) setNegative(suggs[0].negative_prompt);
if (suggs[0].width) setWidth(suggs[0].width);
if (suggs[0].height) setHeight(suggs[0].height);
setSuggestionIndex(0);
}
} catch (e) {
// fallback to local heuristic
const title = (context?.section?.heading || context?.title || '').trim();
const subheads: string[] = context?.section?.subheadings || [];
const keyPoints: string[] = context?.section?.key_points || [];
const keywords: string[] = Array.isArray(context?.section?.keywords)
? context?.section?.keywords
: (Array.isArray(context?.research?.keywords?.primary_keywords)
? context?.research?.keywords?.primary_keywords
: (context?.research?.keywords?.primary || []));
const primary = keywords?.slice(0, 5).filter(Boolean).join(', ');
const audience = context?.persona?.audience || 'content creators and digital marketers';
const industry = context?.persona?.industry || context?.research?.domain || 'your industry';
const tone = context?.persona?.tone || 'professional, trustworthy';
const narrativeHints = [
subheads?.length ? `Subheadings: ${subheads.slice(0,3).join(' | ')}` : null,
keyPoints?.length ? `Key points: ${keyPoints.slice(0,3).join(' | ')}` : null,
].filter(Boolean).join('. ');
setPrompt(`${title}${narrativeHints}. Emphasis: ${primary}. Audience: ${audience}. Industry: ${industry}. Tone: ${tone}.`);
} finally {
setLoadingSuggestions(false);
}
};
const onGenerate = async () => {
const req: ImageGenerationRequest = { prompt, negative_prompt: negative, provider, model, width, height };
const res = await generate(req);
if (res && onImageReady) onImageReady(res.image_base64);
// publish to image bus for downstream consumers (e.g., SEO metadata modal)
try {
const { publishImage } = await import('../../utils/imageBus');
publishImage({ base64: res.image_base64, provider: res.provider, model: res.model });
} catch {}
};
useImperativeHandle(ref, () => ({
suggest: () => suggestPrompt(),
generate: () => onGenerate(),
openAdvanced: () => setShowAdvanced(v => !v),
closeAdvanced: () => setShowAdvanced(false)
}));
return (
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#202124' }}>Generate Blog Section Image</Typography>
{/* Advanced Options in Header Area */}
<Collapse in={showAdvanced}>
<Box sx={{ mb: 2, border: '1px solid #e0e0e0', borderRadius: 1, p: 1.5, backgroundColor: '#fafafa', color: '#202124' }}>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<Tooltip title="Select the AI image generation provider. Hugging Face offers photorealistic Flux models, Gemini provides brand-safe editorial images, and Stability AI delivers SDXL-quality professional outputs." placement="top" arrow>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value as Provider)} sx={textInputSx} MenuProps={{ PaperProps: { sx: { color: '#202124' } } }}>
<MenuItem value="huggingface">Hugging Face</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
<MenuItem value="stability">Stability</MenuItem>
</Select>
</FormControl>
</Tooltip>
</Grid>
<Grid item xs={12} md={5}>
<Tooltip title="Specify the exact model to use. Leave empty to use the provider's default. For Hugging Face, the default is FLUX.1-Krea-dev, optimized for photorealistic blog images." placement="top" arrow>
<TextField fullWidth label="Model" value={model} onChange={(e) => setModel(e.target.value)} helperText={provider === 'huggingface' ? 'Default: black-forest-labs/FLUX.1-Krea-dev' : 'Leave empty to use provider default'} sx={textInputSx} />
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image width in pixels. Recommended: 1024 for square images, 1920 for landscape covers. Higher values increase quality but take longer to generate." placement="top" arrow>
<TextField fullWidth type="number" label="Width" value={width} onChange={(e) => setWidth(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image height in pixels. Recommended: 1024 for square images, 1080 for portrait covers. Aspect ratio affects composition and visual appeal." placement="top" arrow>
<TextField fullWidth type="number" label="Height" value={height} onChange={(e) => setHeight(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
</Tooltip>
</Grid>
</Grid>
</Box>
</Collapse>
{/* Loading indicators */}
{loadingSuggestions && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Loading suggestions...</Typography>
<LinearProgress />
</Box>
)}
{isGenerating && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Generating image...</Typography>
<LinearProgress />
</Box>
)}
{/* Prompt and Negative Prompt Side by Side - 80/20 split, stack on mobile */}
<Box sx={{ mb: 2, display: { xs: 'block', md: 'flex' }, gap: 2 }}>
<Tooltip title="Describe what you want in the image. Be specific: mention style (photorealistic, editorial, cinematic), subjects, composition, lighting, and mood. The AI uses this to generate your image. Tips: Include camera settings (e.g., '50mm lens, f/2.8'), lighting direction, and visual emphasis." placement="top" arrow>
<TextField
sx={{ flex: { md: '0 0 80%' }, width: { xs: '100%' }, mb: { xs: 2, md: 0 } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
multiline
minRows={3}
label="Prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the image..."
/>
</Tooltip>
<Tooltip title="List elements you want to avoid in the image (e.g., blurry, cartoon, watermark, low quality). This helps the AI exclude unwanted features. Common items: text artifacts, brand logos, distorted anatomy, oversaturation, noise." placement="top" arrow>
<TextField
sx={{ flex: { md: '0 0 20%' }, width: { xs: '100%' } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
multiline
minRows={3}
label="Negative Prompt (optional)"
value={negative}
onChange={(e) => setNegative(e.target.value)}
/>
</Tooltip>
</Box>
{/* Action Buttons */}
<Grid container spacing={2}>
<Grid item xs={12}>
<Tooltip title="Get AI-generated prompt suggestions tailored to your blog section. Uses your section title, subheadings, key points, keywords, and research data to create hyper-personalized prompts optimized for your chosen provider. Click to see multiple suggestions in tabs." placement="top" arrow>
<span>
<Button sx={{ mr: 1 }} variant="outlined" onClick={suggestPrompt} disabled={loadingSuggestions}>{loadingSuggestions ? 'Suggesting…' : 'Suggest prompt'}</Button>
</span>
</Tooltip>
<Tooltip title="Generate the image using your current prompt and settings. The process may take 10-30 seconds depending on provider and image size. Once generated, the image will appear below and can be used for your blog section." placement="top" arrow>
<span>
<Button variant="contained" disabled={!canGenerate} onClick={onGenerate} startIcon={isGenerating ? <CircularProgress size={18} /> : undefined}>
{isGenerating ? 'Generating…' : 'Generate Image'}
</Button>
</span>
</Tooltip>
</Grid>
{error && (
<Grid item xs={12}>
<Typography color="error" variant="body2">{error}</Typography>
</Grid>
)}
{result && (
<Grid item xs={12}>
<Card sx={{ maxWidth: 512 }}>
<CardMedia component="img" image={`data:image/png;base64,${result.image_base64}`} alt="generated" />
</Card>
</Grid>
)}
{suggestions.length > 0 && (
<Grid item xs={12}>
<Tooltip title="Browse through AI-generated prompt suggestions. Each tab shows a different prompt optimized for your section and provider. Click a tab to preview and auto-fill the prompt fields. You can then modify or use it directly." placement="top" arrow>
<div>
<Tabs value={suggestionIndex} onChange={(e, v) => {
setSuggestionIndex(v);
const s = suggestions[v];
if (s) {
setPrompt(s.prompt || '');
setNegative(s.negative_prompt || '');
if (s.width) setWidth(s.width);
if (s.height) setHeight(s.height);
}
}} variant="scrollable" scrollButtons allowScrollButtonsMobile>
{suggestions.map((_, i) => (
<Tab key={i} label={`Prompt ${i + 1}`} />
))}
</Tabs>
</div>
</Tooltip>
<Tooltip title="Preview of the currently selected prompt suggestion. Shows the main prompt and negative prompt (if any). This preview updates when you click different tabs above." placement="top" arrow>
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderTop: 'none', borderRadius: '0 0 8px 8px', background: '#fff' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Preview</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#202124' }}>{suggestions[suggestionIndex]?.prompt}</Typography>
{suggestions[suggestionIndex]?.negative_prompt && (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 1 }}>Negative: {suggestions[suggestionIndex]?.negative_prompt}</Typography>
)}
</Box>
</Tooltip>
</Grid>
)}
</Grid>
</Box>
);
});
export default ImageGenerator;

View File

@@ -0,0 +1,109 @@
import React, { useMemo, useRef } from 'react';
import { Tooltip } from '@mui/material';
import ImageGenerator, { ImageGeneratorHandle } from './ImageGenerator';
interface ImageGeneratorModalProps {
isOpen: boolean;
onClose: () => void;
defaultPrompt?: string;
context?: any;
onImageGenerated?: (imageBase64: string, sectionId?: string) => void;
}
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 2000,
display: 'flex',
justifyContent: 'center',
alignItems: 'stretch'
};
const modalStyle: React.CSSProperties = {
background: '#fff',
width: '100%',
maxWidth: '1200px',
margin: '24px',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
};
const headerStyle: React.CSSProperties = {
padding: '16px 20px',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: '#202124'
};
const bodyStyle: React.CSSProperties = {
padding: 20,
overflow: 'auto',
flex: 1
};
const ImageGeneratorModal: React.FC<ImageGeneratorModalProps> = ({ isOpen, onClose, defaultPrompt, context, onImageGenerated }) => {
const handleImageReady = (base64: string) => {
if (onImageGenerated) {
onImageGenerated(base64, context?.section?.id || context?.sectionId);
}
};
const imageRef = useRef<ImageGeneratorHandle>(null);
const sectionTitle = useMemo(() => context?.section?.heading || context?.title || 'Generate Blog Section Image', [context]);
if (!isOpen) return null;
return (
<div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h3 style={{ margin: 0 }}>{sectionTitle}</h3>
<span style={{ fontSize: 12, color: '#5f6368' }}>Generate Blog Section Image</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tooltip title="Toggle advanced image generation settings. Opens provider selection (Hugging Face, Gemini, Stability AI), model specification, and image dimensions (width/height). Hover or click to show/hide these options." placement="bottom" arrow>
<button
onMouseEnter={() => imageRef.current?.openAdvanced()}
onClick={() => {
// toggle
if (imageRef.current) {
imageRef.current.openAdvanced();
}
}}
style={{ border: '1px solid #cbd5e1', background: '#ffffff', color: '#334155', borderRadius: 20, padding: '6px 12px', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}
>
Advanced Image Options
</button>
</Tooltip>
<Tooltip title="Get AI-powered prompt suggestions tailored to your blog section. Uses section title, subheadings, key points, keywords, and research data to generate multiple hyper-personalized prompts. Suggestions appear as tabs below." placement="bottom" arrow>
<button
onClick={() => imageRef.current?.suggest()}
style={{ border: '1px solid #1976d2', background: '#fff', color: '#1976d2', borderRadius: 20, padding: '6px 12px', cursor: 'pointer' }}
>
Suggest Prompt
</button>
</Tooltip>
<Tooltip title="Close the image generator modal. Any generated images are saved and will appear in your blog section." placement="bottom" arrow>
<button onClick={onClose} style={{ border: '1px solid #ddd', background: '#f5f5f5', borderRadius: 6, padding: '6px 10px', cursor: 'pointer' }}>Close</button>
</Tooltip>
</div>
</div>
<div style={bodyStyle}>
<ImageGenerator ref={imageRef} defaultPrompt={defaultPrompt || ''} context={context} onImageReady={handleImageReady} />
</div>
</div>
</div>
);
};
export default ImageGeneratorModal;

View File

@@ -0,0 +1,73 @@
import { useCallback, useState } from 'react';
import { apiClient } from '../../api/client';
export interface ImageGenerationRequest {
prompt: string;
negative_prompt?: string;
provider?: 'gemini' | 'huggingface' | 'stability';
model?: string;
width?: number;
height?: number;
guidance_scale?: number;
steps?: number;
seed?: number;
}
export interface ImageGenerationResponse {
success: boolean;
image_base64: string;
width: number;
height: number;
provider: string;
model?: string;
seed?: number;
}
export function useImageGeneration() {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ImageGenerationResponse | null>(null);
const generate = useCallback(async (req: ImageGenerationRequest) => {
setIsGenerating(true);
setError(null);
try {
const { data } = await apiClient.post<ImageGenerationResponse>('/api/images/generate', req);
setResult(data);
return data;
} catch (e: any) {
const message = e?.response?.data?.detail || e?.message || 'Image generation failed';
setError(message);
throw new Error(message);
} finally {
setIsGenerating(false);
}
}, []);
return { isGenerating, error, result, generate };
}
export interface PromptSuggestion {
prompt: string;
negative_prompt?: string;
width?: number;
height?: number;
overlay_text?: string;
}
export async function fetchPromptSuggestions(payload: any): Promise<PromptSuggestion[]> {
const res = await fetch('/api/images/suggest-prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to fetch prompt suggestions');
}
const data = await res.json();
return data.suggestions || [];
}

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { BlogResearchResponse, BlogOutlineSection } from '../services/blogWriterApi';
export interface Phase {
id: string;
name: string;
icon: string;
description: string;
completed: boolean;
current: boolean;
disabled: boolean;
}
export const usePhaseNavigation = (
research: BlogResearchResponse | null,
outline: BlogOutlineSection[],
outlineConfirmed: boolean,
hasContent: boolean,
contentConfirmed: boolean,
seoAnalysis: any,
seoMetadata: any,
seoRecommendationsApplied?: boolean
) => {
// Initialize from localStorage if available
const getInitialPhase = (): string => {
try {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem('blogwriter_current_phase');
if (stored) return stored;
}
} catch {}
return 'research';
};
const [currentPhase, setCurrentPhase] = useState<string>(getInitialPhase());
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
try {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem('blogwriter_user_selected_phase');
return stored === 'true';
}
} catch {}
return false;
});
const lastClickAtRef = useRef<number>(0);
// Determine phase states based on current data
const phases = useMemo((): Phase[] => {
const researchCompleted = !!research;
const outlineCompleted = outline.length > 0;
const contentCompleted = hasContent && contentConfirmed;
// SEO is complete when analysis exists AND recommendations are applied
const seoCompleted = !!seoAnalysis && (seoRecommendationsApplied === true || !!seoMetadata);
return [
{
id: 'research',
name: 'Research',
icon: '🔍',
description: 'Research your topic and gather data',
completed: researchCompleted,
current: currentPhase === 'research',
disabled: false // Research is always accessible
},
{
id: 'outline',
name: 'Outline',
icon: '📝',
description: 'Create and refine your blog outline',
completed: outlineCompleted,
current: currentPhase === 'outline',
disabled: !researchCompleted // Disabled only if research not completed (can always go back if completed)
},
{
id: 'content',
name: 'Content',
icon: '✍️',
description: 'Generate and edit your blog content',
completed: contentCompleted,
current: currentPhase === 'content',
disabled: !outlineCompleted // Disabled only if outline not completed (can always go back if completed)
},
{
id: 'seo',
name: 'SEO',
icon: '📈',
description: 'Optimize for search engines',
completed: seoCompleted,
current: currentPhase === 'seo',
disabled: !contentCompleted // Disabled only if content not completed (can always go back if completed)
},
{
id: 'publish',
name: 'Publish',
icon: '🚀',
description: 'Publish your blog post',
completed: false, // This would be set when actually published
current: currentPhase === 'publish',
disabled: !seoCompleted // Can access if SEO done
}
];
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, currentPhase]);
// Persist current phase and user selection
useEffect(() => {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem('blogwriter_current_phase', currentPhase);
window.localStorage.setItem('blogwriter_user_selected_phase', String(userSelectedPhase));
}
} catch {}
}, [currentPhase, userSelectedPhase]);
// Validate stored phase against current availability (quiet)
useEffect(() => {
const current = phases.find(p => p.id === currentPhase);
if (!current) {
setCurrentPhase('research');
return;
}
if (current.disabled) {
// Find the first non-disabled phase in order of progression the user qualifies for
const fallback = phases.find(p => !p.disabled) || ({ id: 'research' } as Phase);
if (fallback.id !== currentPhase) {
setCurrentPhase(fallback.id);
}
}
}, [phases, currentPhase]);
// Auto-update current phase based on completion status (only if user hasn't manually selected a phase)
useEffect(() => {
if (userSelectedPhase) {
return; // Don't auto-update if user has manually selected a phase
}
// Auto-progress to the next available phase when conditions are met
if (research && outline.length === 0) {
// Research completed, but no outline yet - stay on research
if (currentPhase !== 'research') {
setCurrentPhase('research');
}
} else if (research && outline.length > 0 && !outlineConfirmed) {
// Outline created but not confirmed - move to outline phase
if (currentPhase !== 'outline') {
setCurrentPhase('outline');
}
} else if (outlineConfirmed && hasContent && !contentConfirmed) {
// Content generated but not confirmed - move to content phase
if (currentPhase !== 'content') {
setCurrentPhase('content');
}
} else if (contentConfirmed && !seoAnalysis) {
// Content confirmed but no SEO analysis yet - move to SEO phase
if (currentPhase !== 'seo') {
setCurrentPhase('seo');
}
} else if (seoAnalysis && !seoRecommendationsApplied && !seoMetadata) {
// SEO analysis done but recommendations not applied - stay on SEO phase
if (currentPhase !== 'seo') {
setCurrentPhase('seo');
}
} else if (seoAnalysis && (seoRecommendationsApplied || seoMetadata)) {
// SEO recommendations applied or metadata generated
if (currentPhase === 'seo') {
// CRITICAL: Stay in SEO phase so user can review updated content - don't auto-progress
// User will manually navigate to publish when ready
// This prevents blank screen by keeping user in SEO phase where BlogEditor is visible
// No action needed - already in SEO phase, stay here
} else {
// User is NOT in SEO phase - can progress to publish
// This handles cases where user navigates away and comes back
// Only auto-progress if user is already in a different phase (not actively in SEO)
if (currentPhase !== 'publish') {
setCurrentPhase('publish');
}
}
}
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, currentPhase, userSelectedPhase]);
const navigateToPhase = useCallback((phaseId: string) => {
// Minimal debounce (200ms) to avoid race conditions on rapid clicks
const now = Date.now();
if (now - lastClickAtRef.current < 200) { return; }
lastClickAtRef.current = now;
const phase = phases.find(p => p.id === phaseId);
if (phase && !phase.disabled) {
setCurrentPhase(phaseId);
setUserSelectedPhase(true); // Mark that user has manually selected a phase
} else {
// Quietly ignore blocked navigation
}
}, [phases, currentPhase]);
// Reset user selection when a new phase is completed (to allow auto-progression)
const resetUserSelection = () => {
setUserSelectedPhase(false);
};
return {
phases,
currentPhase,
navigateToPhase,
setCurrentPhase,
resetUserSelection
};
};
export default usePhaseNavigation;

View File

@@ -166,15 +166,53 @@ export interface BlogSectionResponse {
continuity_metrics?: { flow?: number; consistency?: number; progression?: number };
}
export interface BlogSEOActionableRecommendation {
category: string;
priority: 'High' | 'Medium' | 'Low' | string;
recommendation: string;
impact: string;
}
export interface BlogSEOAnalysisSummary {
overall_grade: string;
status: string;
strongest_category: string;
weakest_category: string;
key_strengths: string[];
key_weaknesses: string[];
ai_summary: string;
}
export interface BlogSEOAnalyzeResponse {
success: boolean;
seo_score: number;
density: Record<string, any>;
structure: Record<string, any>;
readability: Record<string, any>;
link_suggestions: any[];
image_alt_status: Record<string, any>;
recommendations: string[];
analysis_id?: string;
overall_score: number;
category_scores: Record<string, number>;
analysis_summary: BlogSEOAnalysisSummary;
actionable_recommendations: BlogSEOActionableRecommendation[];
detailed_analysis?: any;
visualization_data?: any;
generated_at?: string;
error?: string;
}
export interface BlogSEOApplyRecommendationsRequest {
title: string;
sections: Array<{ id: string; heading: string; content: string }>;
outline: BlogOutlineSection[];
research: Record<string, any>;
recommendations: BlogSEOActionableRecommendation[];
persona?: Record<string, any>;
tone?: string;
audience?: string;
}
export interface BlogSEOApplyRecommendationsResponse {
success: boolean;
title?: string;
sections: Array<{ id: string; heading: string; content: string; notes?: string[] }>;
applied?: Array<{ category: string; summary: string }>;
error?: string;
}
export interface BlogSEOMetadataResponse {
@@ -263,6 +301,11 @@ export const blogWriterApi = {
return data;
},
async applySeoRecommendations(payload: BlogSEOApplyRecommendationsRequest): Promise<BlogSEOApplyRecommendationsResponse> {
const { data } = await apiClient.post('/api/blog/seo/apply-recommendations', payload);
return data;
},
// Flow Analysis APIs
async analyzeFlowBasic(payload: {
title: string;

View File

@@ -0,0 +1,99 @@
/**
* Debug utility for controlling frontend logging
*/
// Check for debug mode via localStorage or URL parameter
const getDebugMode = (): boolean => {
// Check URL parameter first (e.g., ?debug=true)
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const urlDebug = urlParams.get('debug');
if (urlDebug === 'true') return true;
if (urlDebug === 'false') return false;
// Check localStorage
const stored = localStorage.getItem('alwrity-debug');
if (stored === 'true') return true;
if (stored === 'false') return false;
}
// Default to false in production, true in development
return process.env.NODE_ENV === 'development';
};
let isDebugMode = getDebugMode();
export const debug = {
/**
* Check if debug mode is enabled
*/
isEnabled: () => isDebugMode,
/**
* Enable debug mode
*/
enable: () => {
isDebugMode = true;
if (typeof window !== 'undefined') {
localStorage.setItem('alwrity-debug', 'true');
}
},
/**
* Disable debug mode
*/
disable: () => {
isDebugMode = false;
if (typeof window !== 'undefined') {
localStorage.setItem('alwrity-debug', 'false');
}
},
/**
* Toggle debug mode
*/
toggle: () => {
if (isDebugMode) {
debug.disable();
} else {
debug.enable();
}
},
/**
* Log a message only if debug mode is enabled
*/
log: (message: string, ...args: any[]) => {
if (isDebugMode) {
console.log(`🔍 ${message}`, ...args);
}
},
/**
* Log an error (always shown)
*/
error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args);
},
/**
* Log a warning (always shown)
*/
warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args);
},
/**
* Log an info message (always shown)
*/
info: (message: string, ...args: any[]) => {
console.info(` ${message}`, ...args);
}
};
// Expose global toggle for easy access
if (typeof window !== 'undefined') {
(window as any).toggleDebug = debug.toggle;
(window as any).debugMode = debug;
}

View File

@@ -0,0 +1,18 @@
type ImagePayload = { base64: string; provider?: string; model?: string };
const subscribers = new Set<(p: ImagePayload) => void>();
export function publishImage(payload: ImagePayload) {
subscribers.forEach((cb) => {
try { cb(payload); } catch {}
});
}
export function subscribeImage(cb: (p: ImagePayload) => void) {
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}